Compare commits

..
Author SHA1 Message Date
KoalaTrapLord f47b5182d2 v28: Coach chat UI, analytics/trends tabs, export-to-sim, precon refresh
Features:
- Complete coach page rewrite: chat-based UI with SSE streaming, multi-turn conversations
- Coach cards-like suggestions and apply-recommendation integration
- Coach session management (save/load/delete)
- Export-to-sim button in deckbuilder
- Analytics tab with game stats visualization
- Trends tab with meta trend charts
- Precon refresh button in AI Lab
- Updated installer (v28)
2026-03-12 06:40:41 +00:00
KoalaTrapLord 5f606fee22 v27.5: Revert to legacy HTML/JS/CSS UI as primary interface
- lab_api.py: Flip serving priority — ui/ folder (legacy) served first, React dist/ only as fallback
- Legacy UI has full features: card images, mana costs, type grouping, collection filters, EDHREC recs, ratio dashboard, commander portraits, 3-column deck builder
- React SPA was causing blank pages and missing functionality
- Installer no longer builds or bundles React dist/
- All 7 pages work: Batch Sim, Collection, Deck Builder, Auto Gen, Simulator, Coach, Training
2026-03-12 04:32:33 +00:00
KoalaTrapLord f5dae81193 v27.4: Fix blank Collection/Decks pages — API response unwrapping
- collection.ts: unwrap {items} -> cards, map camelCase params (pageSize, sortField, etc)
- decks.ts: unwrap {decks} from listDecks, {cards} from getDeckCards, normalize field names (commander_name->commander, total_cards->card_count, role_tag->category)
- decks.ts: transform getDeckAnalysis from backend shape (counts_by_type, color_pips) to frontend shape (card_count, land_count, type_distribution, etc)
- types: SetInfo.card_count now optional (backend doesn't provide it)
- Installer now bundles pre-built React dist/ as fallback
2026-03-12 03:47:20 +00:00
KoalaTrapLord 36b3c8c55b v27.3: Fix blank page — response shape mismatches
1. /api/lab/status: StatusResponse now includes run_id, games_completed,
   total_games, current_decks aliases so the React SPA gets the field
   names it expects.

2. /api/lab/decks returns {decks: [...]}, /api/lab/history returns
   {results: [...]}, /api/lab/precons returns {precons: [...]} — but
   the frontend expected bare arrays. Updated lab.ts API client to
   unwrap these wrapper objects automatically.
2026-03-12 03:36:36 +00:00
KoalaTrapLord a098524b98 v27.2: Fix /api/lab/status 422 crash — make batchId optional
GET /api/lab/status and /api/lab/result required batchId as a
mandatory query param, but the React SPA calls them with no param
on page load. FastAPI returned 422 Unprocessable Content, which
caused the BatchSimPage to crash and the UI to flash then vanish
in a retry loop.

Fix: batchId is now Optional[str] = None. When omitted, returns
the most recent active batch (or idle defaults for status).
2026-03-12 03:31:36 +00:00
KoalaTrapLord acc9d7cd47 v27.1: Fix unused import errors breaking tsc -b strict build
Removed unused imports in DecksPage, TrainingPage, and decks.ts
that caused noUnusedLocals/noUnusedParameters errors during
the React build step in the installer.
2026-03-12 03:26:38 +00:00
KoalaTrapLord 588314b282 v27: Coach mega-upgrade — all 9 improvements
Backend:
- POST /api/coach/chat: Multi-turn coaching chat with SSE streaming
- POST /api/coach/apply: Apply suggestions to deck (cut/add cards)
- GET /api/coach/cards-like: Embedding similarity search with ownership/price
- GET /api/coach/decks: Enhanced to show all decks with report status
- Goals config (power level, strategy, budget, focus areas) wired to prompts
- Session history with full diff tracking (cuts/adds/hints)

Frontend (complete CoachPage rewrite):
- Live multi-turn chat with streaming LLM responses
- Apply suggestions UI: checkbox cuts/adds, one-click apply to deck
- Goals config panel (power level slider, strategy chips, budget, focus areas)
- Session results view with summary, tips, mana advice, cuts, adds
- Session history browser with session drill-down
- Cards Like This search (embedding-powered card discovery)
- Price-aware card suggestions with collection ownership badges
- Responsive dark theme, tab navigation
2026-03-12 03:15:30 +00:00
KoalaTrapLord 7132b6f205 v26: Wire RL training pipeline UI (phases 1-5)
- Fixed all ML types to match actual backend response shapes:
  MLStatus (logging state + decision files), MLDataStatus (datasets,
  checkpoints, eval results), MLTrainStatus (phases, metrics, result),
  PPOTrainStatus (metrics with win_rate/reward/losses), TournamentStatus
- Updated training API client with full parameter support for all endpoints
- Rewrote TrainingPage with 5 clear pipeline phases:
  Phase 1: Data Collection (toggle logging, view decision files)
  Phase 2: Dataset & Model Artifacts (splits, checkpoints, eval, model info)
  Phase 3: Supervised Training (epochs/lr/batch/patience, live progress,
           phase badges, real-time metrics, final eval results)
  Phase 4: PPO Reinforcement Learning (full config: iterations, episodes,
           clip epsilon, entropy coeff, opponent selection, live win rate)
  Phase 5: Tournament Evaluation (episodes, playstyle, leaderboard table)
- All sections are collapsible with live phase badges and progress bars
- Advanced config toggles for supervised and PPO training
- Proper mutex: can't start PPO while supervised is running, etc.
- Auto-refresh model info and data status when training completes
2026-03-12 02:54:30 +00:00
KoalaTrapLord 1704d7ca9c v25: EDHREC recommendations tab in deck builder
- Redesigned Recommendations tab with EDHREC / From Collection sub-tabs
- EDHREC sub-tab: fetches average deck data, groups by role, shows owned/missing status
- Collection sub-tab: shows cards from collection grouped by type, shortfall indicators
- Both sub-tabs support: checkbox selection, bulk add to deck, single add buttons
- Role badges with color-coding (Ramp, Draw, Removal, BoardWipe, etc.)
- Card hover preview for EDHREC recommendations
- Show only owned filter for EDHREC results
- Fixed API client types: EdhRecsResponse, CollectionRecsResponse wrappers
- Added EdhRecCard and CollectionRecCard interfaces
2026-03-12 02:48:52 +00:00
KoalaTrapLord f639703fb4 v24: Resolve GitHub Issues #1-#5
Issue #1 — Winner's life total now captured:
  - Parse verbose Forge log for life-total-change events
  - Track last-known life per seat via 3 regex patterns
  - Winner's final life set from log data (not left at default 40)
  - Losers who died to mill/poison retain their actual life total

Issue #2 — Win condition classification formalized:
  - New WinCondition enum (schema/WinCondition.java) with 8 categories
  - Centralized Forge loss-reason-string → enum mapping (regex-based)
  - UNKNOWN fallback with logging for unrecognized loss reasons
  - All classification now goes through WinCondition.classify()

Issue #3 — JVM startup overhead reduced:
  - Added -XX:+UseSerialGC (lighter GC for batch processes)
  - Added -XX:TieredStopAtLevel=1 (faster JIT, less compilation)
  - ForgeWorker stub class for future persistent batch mode
  - Estimated reduction: ~2-3s → ~1-1.5s per game startup

Issue #4 — Single-threaded throughput optimization:
  - New CLI flags: --ai-simplified, --ai-think-time, --benchmark
  - JVM flags passed through to Forge subprocess
  - Benchmark mode: run 1 game before batch, report wall/Forge/overhead times
  - API support: aiSimplified, aiThinkTimeMs in StartRequest

Issue #5 — Threading, progress, and backpressure:
  - --threads 0 or -1 auto-detects optimal count from CPU topology
  - detectOptimalThreads() estimates physical cores (excludes HT)
  - ProgressCallback interface for real-time sims/sec reporting
  - Per-game progress emitted as [PROGRESS] lines, parsed by lab_api.py
  - --max-queue flag for graceful backpressure (limits concurrent JVMs)
  - Final throughput summary printed at batch completion
  - StatusResponse now includes simsPerSec field

Also: .gitignore restored, lab_api.py updated with all new API fields
2026-03-12 01:26:36 +00:00
KoalaTrapLord cbedfd1ed2 v23: React + TypeScript SPA migration
Complete frontend rewrite from vanilla HTML/CSS/JS to React 19 + TypeScript + Tailwind CSS v4.

Frontend (frontend/commander-ai-lab-ui/):
- Vite + React 19 + TypeScript + Tailwind CSS v4 + React Router
- 7 pages: BatchSim, Collection, Decks, AutoGen, Simulator, Coach, Training
- Reusable components: Spinner, StatusBadge, CardImage, ManaSymbols, EmptyState
- Full typed API client layer (8 modules matching all backend endpoints)
- Recharts for mana curves, win rate charts, progress bars
- Lucide icons throughout
- Custom dark theme with MTG mana color tokens
- Code splitting (recharts + react in separate chunks)

Backend (lab_api.py):
- SPA-aware static file serving with catch-all for client-side routing
- Fallback to legacy ui/ folder if React dist not built
- Updated startup log with SPA route list

Build system:
- .gitignore added (was missing - removes pyc, db, node_modules, etc.)
- build-full-installer-v2.py: v23 with Node.js check + React build step
- Vite build config with manual chunks for optimal loading

Also includes: V3 deck generator fixes, coach improvements, DeepSeek sim modules
2026-03-12 01:12:02 +00:00
KoalaTrapLord be55a02860 Initial commit 2026-03-10 12:13:56 -04:00
109 changed files with 11098 additions and 34966 deletions

No files matched your search

-21
View File
@@ -1,21 +0,0 @@
[run]
source = src/commander_ai_lab
omit =
*/tests/*
*/__pycache__/*
*/web/static/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
if TYPE_CHECKING:
if __name__ == .__main__.:
\.\.\. # ellipsis
show_missing = true
precision = 1
[html]
directory = htmlcov
-11
View File
@@ -1,11 +0,0 @@
# Commander AI Lab — Environment Variables
# Copy this file to .env and fill in your API keys.
# .env is gitignored and will NOT be committed.
# Ximilar API Key (card scanner / visual AI recognition)
# Get yours at: https://app.ximilar.com/
XIMILAR_API_KEY=your-ximilar-api-key-here
# Perplexity API Key (AI deck research & generation)
# Get yours at: https://www.perplexity.ai/settings/api
PPLX_API_KEY=your-perplexity-api-key-here
-145
View File
@@ -1,145 +0,0 @@
# Commander-AI-Lab GUI + LLM Opponents Roadmap
## Project Summary
Build a playable Magic: The Gathering Commander interface where human players face AI opponents powered by a local LLM. The existing Python rules engine handles game legality and state management, while the LLM acts as the decision-making and narration brain for each AI opponent. A Pygame (or web-based) GUI renders the battlefield, zones, and card interactions. Each AI opponent gets a unique personality prompt to simulate different playstyles (aggro, control, combo, politics).
**Local LLM:** GPT-OSS 20B (Q4_K_M) via Ollama or LM Studio
---
## GUI Strategy: Two-Phase Frontend Plan
### Phase A (Prototype) — Pygame
Use **Pygame** for Phases 1–3 of development. Pure Python means the rules engine and AI opponent code plug in directly with zero bridging. Fast to iterate — a working battlefield renderer can be built in a day. Handles game loop, input events, and card rendering naturally. Ideal for testing AI decisions visually before investing in a polished UI.
### Phase B (Production) — Flask/FastAPI + Web Frontend
Once the core AI gameplay is working, migrate the UI to a **Flask or FastAPI backend + HTML/JS frontend** (vanilla JS or React). Benefits:
- Far more polished look for a Commander game
- Card art from Scryfall API renders natively in browser
- Easier to add multiplayer later via WebSockets
- Python backend stays pure — frontend calls it via REST API
- No Python/JS bridge complexity; clean separation of concerns
### GUI Framework Comparison
| Option | Language | Curve | Best For | Weakness |
|---|---|---|---|---|
| **Pygame** ✅ Phase A | Python | Low | Quick prototype, game loops | Dated UI, manual widgets |
| **Flask + HTML/JS** ✅ Phase B | Python + JS | Medium | Polished UI, browser-based | JS context switch |
| PyQt6 / PySide6 | Python | Medium | Desktop app widgets | Verbose, overkill for a game |
| Godot | GDScript | Medium | Full game engine, animations | Separate codebase from engine |
| Electron + Python | JS + Python | High | Desktop with web UI | Heavy, complex bridge |
| Tkinter | Python | Low | Simple UI | Very dated look |
---
## Architecture Overview
```
Game State (Python dict/dataclass)
↓
Legal Moves Generator (rules engine)
↓
LLM Prompt: "Given this state and these legal moves, what do you do?"
↓ (GPT-OSS 20B Q4_K_M)
LLM Response → parsed action
↓
Rules Engine executes + validates
↓
GUI updates board state (Pygame → Web)
```
---
## Task List
### Phase 1 — Game State Serialization
- [ ] Define a compressed game state schema (Python dataclass or dict) covering: hand, battlefield, graveyard, exile, stack, life totals, commander zone, mana pool
- [ ] Write a `state_to_prompt()` function that converts game state to a compact LLM-readable string
- [ ] Write a `legal_moves_to_prompt()` function that lists valid actions in numbered format for LLM selection
- [ ] Add a state snapshot logger for debugging AI decisions
### Phase 2 — LLM AI Opponent Integration
- [ ] Load GPT-OSS 20B (Q4_K_M) in Ollama or LM Studio; verify inference speed on RTX 5070 Ti
- [ ] Benchmark VRAM usage for GPT-OSS 20B Q4_K_M (expected ~12–14 GB VRAM at Q4_K_M quantization)
- [ ] Build `AIOpponent` class with fields: name, personality_prompt, model_endpoint, deck
- [ ] Implement `decide_action(game_state, legal_moves)` method — sends prompt to GPT-OSS 20B, parses LLM response, returns validated action index
- [ ] Add fallback logic: if LLM returns invalid/unparseable action, default to highest-priority heuristic move
- [ ] Write personality prompt templates (Aggro Timmy, Control Spike, Combo Johnny, Political Negotiator)
- [ ] Add narration method: `narrate_play(action)` — GPT-OSS 20B generates in-character flavor text for each play
- [ ] Tune system prompt length to stay within GPT-OSS 20B context window limits
### Phase 3 — Multi-Player Turn Management
- [ ] Implement turn queue: Player → AI1 → AI2 → AI3 → repeat
- [ ] Run AI decisions asynchronously (threading or asyncio) so GUI stays responsive during LLM inference
- [ ] Add priority-passing logic for instants/triggers during other players' turns
- [ ] Implement basic AI threat assessment: each AI scores opponents by board state danger level
### Phase 4 — Pygame Prototype GUI
- [ ] Design zone layout: hand, battlefield (creature/non-creature split), graveyard, exile, command zone, stack
- [ ] Build card rendering: card art placeholder, name, P/T or loyalty, tapped/untapped state
- [ ] Add click-to-play card interaction for human player
- [ ] Display AI narration/flavor text in a side panel
- [ ] Add life total trackers and commander damage matrix (4-player)
- [ ] Build phase/step indicator (Main 1, Combat, Main 2, etc.)
- [ ] Add end-of-game screen with win condition display
### Phase 5 — Web Frontend Migration *(Flask/FastAPI + HTML/JS)*
- [ ] Set up Flask or FastAPI backend exposing game state as REST API
- [ ] Build HTML/JS board layout mirroring Pygame zone design
- [ ] Integrate Scryfall API for card art rendering
- [ ] Add WebSocket support for real-time game state updates
- [ ] Add drag-and-drop card interaction
- [ ] Port AI narration panel to web chat-style UI
- [ ] Test full game loop end-to-end on web frontend
### Phase 6 — Politics & Table Talk *(Stretch Goal)*
- [ ] Implement inter-AI negotiation: before attacking, AI queries GPT-OSS 20B "should I propose a deal?"
- [ ] Build deal proposal UI: AI proposes, human can accept/decline, other AIs respond
- [ ] Add threat-based targeting memory: AIs remember who attacked them last turn
### Phase 7 — Testing & Tuning
- [ ] Run 10 full simulated games (4 AI vs AI) and log decision quality
- [ ] Benchmark GPT-OSS 20B Q4_K_M inference time per decision on RTX 5070 Ti
- [ ] Tune context window compression to stay within model token limits
- [ ] Profile Pygame and web render loops for stability during AI thinking phases
- [ ] Add unit tests for `state_to_prompt()` and `decide_action()` parsing
### Phase 8 — Multiplayer (Human vs Human + AI)
- [ ] **Lobby system** — create/join game rooms with configurable seat count (2–4 players); assign each seat as Human or AI
- [ ] **WebSocket game server** — upgrade Flask/FastAPI backend to handle persistent connections for real-time state sync across multiple browser clients
- [ ] **Player session management** — unique session tokens per human player; reconnect support if connection drops mid-game
- [ ] **Private hand views** — each human player sees only their own hand; other hands shown as face-down card backs
- [ ] **Turn notification system** — alert active player when it's their turn (browser notification or in-page indicator)
- [ ] **Action broadcast** — all players see each action played in real-time (card played, attack declared, ability activated) via WebSocket push
- [ ] **Mixed lobby support** — allow any combination of human and AI seats (e.g., 2 humans + 2 AIs, or 1 human + 3 AIs)
- [ ] **Spectator mode** — allow observers to watch a game without participating; full board visible, no hand info
- [ ] **Local LAN play** — host game server on local network so friends can connect via browser on the same Wi-Fi (no internet required)
- [ ] **Optional: Remote play** — expose server via ngrok or Tailscale for play over the internet without full deployment
- [ ] **Multiplayer AI pacing** — add configurable AI decision delay (e.g., 1–3 sec) so human players can follow AI turns without them resolving instantly
- [ ] **Chat panel** — in-game text chat between human players; AI opponents can optionally post narration lines into the same channel
- [ ] **End-of-game summary** — show full game recap (turns played, damage dealt, commanders cast count) visible to all players after win condition met
---
## Tech Stack
| Component | Choice | Notes |
|---|---|---|
| GUI (Prototype) | **Pygame** | Pure Python, fast iteration |
| GUI (Production) | **Flask/FastAPI + HTML/JS** | Polished, browser-based |
| Multiplayer | **WebSockets (Flask-SocketIO)** | Real-time state sync across clients |
| LLM Backend | Ollama or LM Studio | Serves GPT-OSS 20B locally |
| Model | **GPT-OSS 20B (Q4_K_M)** | ~12–14 GB VRAM, strong reasoning |
| Game State | Python dataclass | Structured, easy to serialize |
| Async | asyncio / threading | Keep GUI responsive during inference |
| Card Data | Scryfall API | Free MTG card data + art |
| LAN Hosting | Local network / ngrok | No deployment needed for local play |
### GPT-OSS 20B Q4_K_M Notes
- Q4_K_M quantization balances quality and speed well for a 20B model
- Expected VRAM: ~12–14 GB on RTX 5070 Ti (16 GB VRAM) — fits comfortably
- Stronger reasoning than 7B models — better for multi-step MTG decision making
- Slower inference than smaller models — async handling is essential so GUI doesn't block
- System prompt + game state should be kept under ~3000 tokens to maintain fast response times
-65
View File
@@ -1,65 +0,0 @@
# Commander AI Lab — Developer Makefile
# ======================================
# Targets:
# make test — run all tests
# make test-unit — run unit tests only (fast)
# make test-integration— run integration tests
# make test-benchmark — run benchmark suite with -s (print timings)
# make coverage — run tests with coverage report
# make serve — start the FastAPI dev server
# make lint — ruff + mypy
# make fmt — ruff format
# make clean — remove build artefacts
PYTHON ?= python
UVICORN ?= uvicorn
PORT ?= 8000
SRC = src/commander_ai_lab
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean
test:
$(PYTHON) -m pytest tests/ -q
test-unit:
$(PYTHON) -m pytest tests/ -q -m "not integration and not benchmark"
test-integration:
$(PYTHON) -m pytest tests/test_integration.py -v
test-benchmark:
$(PYTHON) -m pytest tests/test_benchmark.py -v -s
test-phase3:
$(PYTHON) -m pytest tests/test_phase3.py -v
test-phase4:
$(PYTHON) -m pytest tests/test_phase4.py -v
test-phase5:
$(PYTHON) -m pytest tests/test_phase5.py -v
test-phase6:
$(PYTHON) -m pytest tests/test_phase6.py -v
test-phase7:
$(PYTHON) -m pytest tests/test_integration.py tests/test_benchmark.py -v
coverage:
$(PYTHON) -m pytest tests/ --cov=$(SRC) --cov-report=term-missing --cov-report=html
echo "Coverage report: htmlcov/index.html"
serve:
$(UVICORN) commander_ai_lab.web.app:app --reload --port $(PORT)
lint:
$(PYTHON) -m ruff check $(SRC) tests/
$(PYTHON) -m mypy $(SRC) --ignore-missing-imports
fmt:
$(PYTHON) -m ruff format $(SRC) tests/
clean:
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name '*.pyc' -delete
rm -rf .coverage htmlcov .mypy_cache .ruff_cache dist build
-49
View File
@@ -1,49 +0,0 @@
# Routes Wiring — Final Integration Step
All route files are in `routes/`. To activate them, add **two lines** to `lab_api.py`:
## Option A — Inline (recommended)
Add this block immediately after the `app.add_middleware(CORSMiddleware, ...)` call
(around line 145 in the current file):
```python
# ── Route Package Wiring ──────────────────────────────────────
from routes import _autoload as _routes_autoload
_routes_autoload.register_startup(app)
```
That's it. `register_startup(app)` attaches an `@app.on_event('startup')` hook
that imports `routes._wire`, which:
1. Calls `_register_shared()` with every symbol from `lab_api.py`
2. Calls `app.include_router()` for all 5 routers
## Option B — Bottom of file
Alternatively, add this at the very bottom of `lab_api.py`
(after all functions/endpoints are defined, before `if __name__ == '__main__'`):
```python
# ── Wire routes package (must be after all symbols are defined) ──
import routes._wire # noqa — side-effect: registers shared state + mounts routers
```
Option B is slightly more efficient (no startup delay) but requires that all
functions referenced in `routes/_wire.py` exist at module level before the
import runs.
## Route Files Summary
| File | Prefix | Endpoints |
|---|---|---|
| `routes/lab.py` | `/api/lab` | start, status, result, decks, history, profiles, analytics, trends, log |
| `routes/import_routes.py` | `/api/lab` | import/url, import/text, meta/* |
| `routes/precon.py` | `/api/lab` | precons/*, precons/install, precons/refresh |
| `routes/collection.py` | *(none)* | /api/collection/*, /api/cache/scryfall/* |
| `routes/deckbuilder.py` | `/api/decks` | CRUD, cards, analysis, recommendations, export |
## Shared Registry
`routes/shared.py` holds a `_registry` dict populated by `routes/_wire.py`.
Every router calls `routes.shared.get('symbol_name')` to access shared state
without circular imports.
+1 -86
View File
@@ -268,92 +268,7 @@ class PerplexityClient:
return json.loads(text[start:i+1])
except (json.JSONDecodeError, TypeError):
return None
# If we get here, JSON was truncated (depth > 0). Try to repair.
return PerplexityClient._repair_truncated_json(text[start:])
@staticmethod
def _repair_truncated_json(text: str) -> Optional[dict]:
"""
Attempt to repair truncated JSON from max_tokens cutoff.
Strategy: find the last complete array element in "cards",
then close all open brackets/braces.
"""
logger.info("Attempting truncated JSON repair (%d chars)...", len(text))
# Find the last complete card object (ends with }) in the cards array
# Walk backwards to find the last closing brace that ends a valid card
last_good = -1
depth = 0
in_string = False
escape_next = False
for i in range(len(text)):
ch = text[i]
if escape_next:
escape_next = False
continue
if ch == '\\' and in_string:
escape_next = True
continue
if ch == '"':
in_string = not in_string
continue
if in_string:
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth >= 1:
# This closes a nested object (like a card) — mark as potential cutoff
last_good = i
if last_good == -1:
return None
# Take text up to the last complete nested object
truncated = text[:last_good + 1]
# Now figure out what we need to close.
# Re-scan to get the closing sequence.
close_depth = 0
in_arr = 0
in_str = False
esc = False
for ch in truncated:
if esc:
esc = False
continue
if ch == '\\' and in_str:
esc = True
continue
if ch == '"':
in_str = not in_str
continue
if in_str:
continue
if ch == '{':
close_depth += 1
elif ch == '}':
close_depth -= 1
elif ch == '[':
in_arr += 1
elif ch == ']':
in_arr -= 1
# Build closing sequence
suffix = ']' * in_arr + '}' * close_depth
repaired = truncated + suffix
try:
result = json.loads(repaired)
card_count = len(result.get('cards', []))
logger.info("JSON repair succeeded: recovered %d cards", card_count)
return result
except (json.JSONDecodeError, TypeError) as e:
logger.warning("JSON repair failed: %s", e)
return None
return None
def check_status(self) -> dict:
"""Quick health check — try a minimal API call."""
+3 -7
View File
@@ -126,12 +126,11 @@ class CoachService:
# ── Main Coaching Pipeline ─────────────────────────────────
async def run_coaching_session(self, deck_id: str,
goals: Optional[CoachGoals] = None,
fallback_report: Optional[DeckReport] = None,
goals: Optional[CoachGoals] = None
) -> CoachSession:
"""
Full coaching pipeline:
1. Load deck report (or use fallback from DB card list)
1. Load deck report
2. Identify underperformers
3. Find replacement candidates via embeddings
4. Build prompt
@@ -151,11 +150,8 @@ class CoachService:
except Exception as e:
logger.warning("Embeddings auto-load failed: %s", e)
# 1. Load deck report — try simulation report first, then fallback
# 1. Load deck report
report = self.load_deck_report(deck_id)
if report is None and fallback_report is not None:
report = fallback_report
logger.info("Using DB-built fallback report for '%s' (no simulation data)", deck_id)
if report is None:
raise ValueError(f"Deck report not found for: {deck_id}")
+1 -1
View File
@@ -45,7 +45,7 @@ DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "perplexity")
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "sonar")
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "8192"))
# ── Smart Substitution Settings ───────────────────────────────
# Minimum embedding similarity to accept a substitute without Perplexity fallback
+1 -7
View File
@@ -31,10 +31,4 @@ IMPORTANT:
- Provide realistic USD price estimates
- The commander MUST be included in the cards list with its real category (Creature, Planeswalker, etc.)
- Assign the correct bracket level based on the cards chosen and combos present
- List Game Changer cards explicitly in the bracket section
OUTPUT EFFICIENCY (critical):
- Keep each card's "reason" under 15 words — brief is better
- Keep "synergy_with" to at most 2-3 card names per card
- Do not repeat the card's own name in synergy_with
- Omit estimated_price_usd if you are unsure — 0 is fine"""
- List Game Changer cards explicitly in the bracket section"""
+3 -61
View File
@@ -243,19 +243,13 @@ class DeckGeneratorV3:
)
raw_deck = response.parsed_json
card_count = len(raw_deck.get("cards", []))
truncated = card_count < 90 # Commander decks should have ~100 cards
logger.info("Deck generated: %d cards, bracket %d%s",
card_count,
raw_deck.get("bracket", {}).get("level", 0),
" (TRUNCATED — response may have hit token limit)" if truncated else "")
logger.info("Deck generated: %d cards, bracket %d",
len(raw_deck.get("cards", [])),
raw_deck.get("bracket", {}).get("level", 0))
# Step 5: Check ownership and build enriched card list
cards_with_status = self._check_ownership(raw_deck.get("cards", []))
# Step 5b: Fill basic lands to hit exactly 100 cards
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
# Step 6: Compute stats
stats = self._compute_stats(cards_with_status)
@@ -276,7 +270,6 @@ class DeckGeneratorV3:
},
"model": response.model,
"citations": response.citations,
"truncated": truncated,
}
# ── Step 5: Ownership Check ──────────────────────────────
@@ -322,57 +315,6 @@ class DeckGeneratorV3:
return enriched
# ── Step 5b: Basic Land Fill ──────────────────────────────
_COLOR_TO_BASIC = {
'W': 'Plains', 'U': 'Island', 'B': 'Swamp',
'R': 'Mountain', 'G': 'Forest',
}
_BASIC_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'}
def _fill_basic_lands(
self,
cards: List[DeckCardWithStatus],
color_identity: List[str],
target_total: int = 100,
) -> List[DeckCardWithStatus]:
"""
Strip LLM-provided basics, then add the right number evenly
across the commander's colors to reach exactly target_total.
"""
non_basics = [c for c in cards if c.name not in self._BASIC_NAMES]
current_total = sum(c.count for c in non_basics)
basics_needed = max(0, target_total - current_total)
if basics_needed == 0:
return non_basics
ci = [c.upper() for c in color_identity]
basic_names = [self._COLOR_TO_BASIC[c] for c in ci if c in self._COLOR_TO_BASIC]
if not basic_names:
basic_names = ['Wastes']
per_color = basics_needed // len(basic_names)
remainder = basics_needed % len(basic_names)
for i, bname in enumerate(basic_names):
qty = per_color + (1 if i < remainder else 0)
if qty > 0:
non_basics.append(DeckCardWithStatus(
name=bname,
count=qty,
category='Land',
role_tags=[],
reason='Basic land for mana fixing',
estimated_price_usd=0.10,
owned=True,
owned_qty=qty,
status='owned',
))
logger.info("Basic land fill: %d basics added across %d color(s)",
basics_needed, len(basic_names))
return non_basics
# ── Step 6: Smart Substitution ───────────────────────────
def run_substitution(
self,
+1 -5
View File
@@ -7,10 +7,6 @@ Currently returns a hardcoded sample for end-to-end wiring.
Real implementation will call the Archidekt API.
"""
from .models import TemplateDeck, TemplateDeckCard
import logging
log = logging.getLogger("commander_ai_lab.deckgen.archidekt")
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
@@ -30,5 +26,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
# 1. Search Archidekt for popular decks with this commander
# 2. Fetch the top 3-5 decklists
# 3. Normalize card names and return as TemplateDeck
log.info(f" Stub: No template decks for '{commander_name}' (not yet implemented)")
print(f" [ARCHIDEKT] Stub: No template decks for '{commander_name}' (not yet implemented)")
return []
+1 -5
View File
@@ -7,10 +7,6 @@ Currently returns a hardcoded sample for end-to-end wiring.
Real implementation will parse EDHREC commander pages.
"""
from .models import TemplateDeck, TemplateDeckCard
import logging
log = logging.getLogger("commander_ai_lab.deckgen.edhrec")
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
@@ -31,5 +27,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
# 2. Fetch the "Average Deck" or "Top Cards" list
# 3. Parse card names and inclusion percentages
# 4. Return as a single TemplateDeck with weighted cards
log.info(f" Stub: No recommendations for '{commander_name}' (not yet implemented)")
print(f" [EDHREC] Stub: No recommendations for '{commander_name}' (not yet implemented)")
return []
+1 -5
View File
@@ -6,10 +6,6 @@ Fetches template decklists from Moxfield for a given commander.
Currently returns empty (experimental — not yet implemented).
"""
from .models import TemplateDeck, TemplateDeckCard
import logging
log = logging.getLogger("commander_ai_lab.deckgen.moxfield")
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
@@ -17,5 +13,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
Fetch template decks from Moxfield for the given commander.
Experimental — not yet implemented.
"""
log.info(f" Stub: Not yet implemented for '{commander_name}'")
print(f" [MOXFIELD] Stub: Not yet implemented for '{commander_name}'")
return []
+1 -5
View File
@@ -6,10 +6,6 @@ Fetches template decklists from MTGGoldfish for a given commander.
Currently returns empty (experimental — not yet implemented).
"""
from .models import TemplateDeck, TemplateDeckCard
import logging
log = logging.getLogger("commander_ai_lab.deckgen.mtggoldfish")
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
@@ -17,5 +13,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
Fetch template decks from MTGGoldfish for the given commander.
Experimental — not yet implemented.
"""
log.info(f" Stub: Not yet implemented for '{commander_name}'")
print(f" [MTGGOLDFISH] Stub: Not yet implemented for '{commander_name}'")
return []
+57 -61
View File
@@ -56,27 +56,25 @@ Install-B64 -R 'coach\__init__.py' -B $b
$b = 'IyBDb2FjaCBjbGllbnRzIOKAlCBQZXJwbGV4aXR5LCBMTSBTdHVkaW8K'
Install-B64 -R 'coach\clients\__init__.py' -B $b
# coach/clients/perplexity_client.py (12,494 bytes)
# coach/clients/perplexity_client.py (9,727 bytes)
$b = 'IiIiCkNvbW1hbmRlciBBSSBMYWIg4oCUIFBlcnBsZXhpdHkgU29uYXIgU3RydWN0dXJlZCBPdXRwdXQgQ2xpZW50CuKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkApVc2VzIHRoZSBvcGVuYWkgU0RLIHRvIGNhbGwgUGVycGxleGl0eSdzIGNoYXQvY29tcGxldGlvbnMgZW5kcG9pbnQKd2l0aCByZXNwb25zZV9mb3JtYXQ6IGpzb25fc2NoZW1hIGZvciBndWFyYW50ZWVkIHN0cnVjdHVyZWQgb3V0cHV0LgoKU3VwcG9ydHMgYm90aCBzb25hciAoZmFzdCwgY2hlYXApIGFuZCBzb25hci1wcm8gKGRlZXAsIHRob3JvdWdoKS4KIiIiCgppbXBvcnQganNvbgppbXBvcnQgbG9nZ2luZwppbXBvcnQgYXN5bmNpbwpmcm9tIHR5cGluZyBpbXBvcnQgT3B0aW9uYWwKCmZyb20gb3BlbmFpIGltcG9ydCBPcGVuQUkKCmxvZ2dlciA9IGxvZ2dpbmcuZ2V0TG9nZ2VyKCJjb2FjaC5wcGx4IikKCiMg4pSA4pSAIFBlcnBsZXhpdHkgTW9kZWxzIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgApTT05BUiA9ICJzb25hciIKU09OQVJfUFJPID0gInNvbmFyLXBybyIKU09OQVJfREVFUF9SRVNFQVJDSCA9ICJzb25hci1kZWVwLXJlc2VhcmNoIgoKCmNsYXNzIFBlcnBsZXhpdHlSZXNwb25zZToKICAgICIiIlBhcnNlZCByZXNwb25zZSBjb250YWluZXIgZnJvbSBQZXJwbGV4aXR5IEFQSS4iIiIKICAgIGRlZiBfX2luaXRfXyhzZWxmLCBjb250ZW50OiBzdHIsIHBhcnNlZF9qc29uOiBPcHRpb25hbFtkaWN0XSwKICAgICAgICAgICAgICAgICBwcm9tcHRfdG9rZW5zOiBpbnQsIGNvbXBsZXRpb25fdG9rZW5zOiBpbnQsCiAgICAgICAgICAgICAgICAgbW9kZWw6IHN0ciwgY2l0YXRpb25zOiBsaXN0W3N0cl0gPSBOb25lKToKICAgICAgICBzZWxmLmNvbnRlbnQgPSBjb250ZW50CiAgICAgICAgc2VsZi5wYXJzZWRfanNvbiA9IHBhcnNlZF9qc29uCiAgICAgICAgc2VsZi5wcm9tcHRfdG9rZW5zID0gcHJvbXB0X3Rva2VucwogICAgICAgIHNlbGYuY29tcGxldGlvbl90b2tlbnMgPSBjb21wbGV0aW9uX3Rva2VucwogICAgICAgIHNlbGYubW9kZWwgPSBtb2RlbAogICAgICAgIHNlbGYuY2l0YXRpb25zID0gY2l0YXRpb25zIG9yIFtdCgogICAgQHByb3BlcnR5CiAgICBkZWYgb2soc2VsZikgLT4gYm9vbDoKICAgICAgICByZXR1cm4gc2VsZi5wYXJzZWRfanNvbiBpcyBub3QgTm9uZQoKCmNsYXNzIFBlcnBsZXhpdHlDbGllbnQ6CiAgICAiIiIKICAgIFBlcnBsZXhpdHkgU29uYXIgQVBJIGNsaWVudCB1c2luZyB0aGUgb3BlbmFpIFNESy4KCiAgICBVc2FnZToKICAgICAgICBjbGllbnQgPSBQZXJwbGV4aXR5Q2xpZW50KGFwaV9rZXk9InBwbHgtLi4uIikKICAgICAgICByZXNwID0gY2xpZW50LmNoYXRfc3RydWN0dXJlZCgKICAgICAgICAgICAgc3lzdGVtX3Byb21wdD0iWW91IGFyZSBhIGRlY2sgYnVpbGRlci4iLAogICAgICAgICAgICB1c2VyX3Byb21wdD0iQnVpbGQgYSBkZWNrIGZvci4uLiIsCiAgICAgICAgICAgIGpzb25fc2NoZW1hPWRlY2tfc2NoZW1hLAogICAgICAgICAgICBzY2hlbWFfbmFtZT0iRGVja0xpc3QiLAogICAgICAgICkKICAgICAgICBpZiByZXNwLm9rOgogICAgICAgICAgICBkZWNrID0gcmVzcC5wYXJzZWRfanNvbgogICAgIiIiCgogICAgZGVmIF9faW5pdF9fKHNlbGYsIGFwaV9rZXk6IHN0ciwgbW9kZWw6IHN0ciA9IFNPTkFSLAogICAgICAgICAgICAgICAgIHRpbWVvdXQ6IGludCA9IDEyMCk6CiAgICAgICAgaWYgbm90IGFwaV9rZXk6CiAgICAgICAgICAgIHJhaXNlIFZhbHVlRXJyb3IoIlBlcnBsZXhpdHkgQVBJIGtleSBpcyByZXF1aXJlZCIpCiAgICAgICAgc2VsZi5hcGlfa2V5ID0gYXBpX2tleQogICAgICAgIHNlbGYubW9kZWwgPSBtb2RlbAogICAgICAgIHNlbGYudGltZW91dCA9IHRpbWVvdXQKICAgICAgICBzZWxmLl9jbGllbnQgPSBPcGVuQUkoCiAgICAgICAgICAgIGFwaV9rZXk9YXBpX2tleSwKICAgICAgICAgICAgYmFzZV91cmw9Imh0dHBzOi8vYXBpLnBlcnBsZXhpdHkuYWkiLAogICAgICAg' +
'ICAgICB0aW1lb3V0PXRpbWVvdXQsCiAgICAgICAgKQoKICAgIGRlZiBjaGF0X3N0cnVjdHVyZWQoCiAgICAgICAgc2VsZiwKICAgICAgICBzeXN0ZW1fcHJvbXB0OiBzdHIsCiAgICAgICAgdXNlcl9wcm9tcHQ6IHN0ciwKICAgICAgICBqc29uX3NjaGVtYTogZGljdCwKICAgICAgICBzY2hlbWFfbmFtZTogc3RyID0gInJlc3BvbnNlIiwKICAgICAgICBtb2RlbDogc3RyID0gTm9uZSwKICAgICAgICB0ZW1wZXJhdHVyZTogZmxvYXQgPSAwLjIsCiAgICAgICAgbWF4X3Rva2VuczogaW50ID0gODE5MiwKICAgICkgLT4gUGVycGxleGl0eVJlc3BvbnNlOgogICAgICAgICIiIgogICAgICAgIFNlbmQgYSBjaGF0IHJlcXVlc3Qgd2l0aCBzdHJ1Y3R1cmVkIEpTT04gb3V0cHV0LgoKICAgICAgICBBcmdzOgogICAgICAgICAgICBzeXN0ZW1fcHJvbXB0OiBTeXN0ZW0gbWVzc2FnZQogICAgICAgICAgICB1c2VyX3Byb21wdDogVXNlciBtZXNzYWdlCiAgICAgICAgICAgIGpzb25fc2NoZW1hOiBKU09OIFNjaGVtYSBkaWN0IGZvciByZXNwb25zZV9mb3JtYXQKICAgICAgICAgICAgc2NoZW1hX25hbWU6IE5hbWUgZm9yIHRoZSBzY2hlbWEgKHVzZWQgaW4gcmVzcG9uc2VfZm9ybWF0KQogICAgICAgICAgICBtb2RlbDogT3ZlcnJpZGUgbW9kZWwgKHNvbmFyLCBzb25hci1wcm8pCiAgICAgICAgICAgIHRlbXBlcmF0dXJlOiBTYW1wbGluZyB0ZW1wZXJhdHVyZSAoMC4w4oCTMS4wKQogICAgICAgICAgICBtYXhfdG9rZW5zOiBNYXggb3V0cHV0IHRva2VucwoKICAgICAgICBSZXR1cm5zOgogICAgICAgICAgICBQZXJwbGV4aXR5UmVzcG9uc2Ugd2l0aCBwYXJzZWRfanNvbiBndWFyYW50ZWVkIGJ5IHNjaGVtYQogICAgICAgICIiIgogICAgICAgIHVzZV9tb2RlbCA9IG1vZGVsIG9yIHNlbGYubW9kZWwKCiAgICAgICAgbWVzc2FnZXMgPSBbCiAgICAgICAgICAgIHsicm9sZSI6ICJzeXN0ZW0iLCAiY29udGVudCI6IHN5c3RlbV9wcm9tcHR9LAogICAgICAgICAgICB7InJvbGUiOiAidXNlciIsICJjb250ZW50IjogdXNlcl9wcm9tcHR9LAogICAgICAgIF0KCiAgICAgICAgdHJ5OgogICAgICAgICAgICByZXNwb25zZSA9IHNlbGYuX2NsaWVudC5jaGF0LmNvbXBsZXRpb25zLmNyZWF0ZSgKICAgICAgICAgICAgICAgIG1vZGVsPXVzZV9tb2RlbCwKICAgICAgICAgICAgICAgIG1lc3NhZ2VzPW1lc3NhZ2VzLAogICAgICAgICAgICAgICAgdGVtcGVyYXR1cmU9dGVtcGVyYXR1cmUsCiAgICAgICAgICAgICAgICBtYXhfdG9rZW5zPW1heF90b2tlbnMsCiAgICAgICAgICAgICAgICByZXNwb25zZV9mb3JtYXQ9ewogICAgICAgICAgICAgICAgICAgICJ0eXBlIjogImpzb25fc2NoZW1hIiwKICAgICAgICAgICAgICAgICAgICAianNvbl9zY2hlbWEiOiB7CiAgICAgICAgICAgICAgICAgICAgICAgICJuYW1lIjogc2NoZW1hX25hbWUsCiAgICAgICAgICAgICAgICAgICAgICAgICJzY2hlbWEiOiBqc29uX3NjaGVtYSwKICAgICAgICAgICAgICAgICAgICB9LAogICAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgKQoKICAgICAgICAgICAgY2hvaWNlID0gcmVzcG9uc2UuY2hvaWNlc1swXQogICAgICAgICAgICBjb250ZW50ID0gY2hvaWNlLm1lc3NhZ2UuY29udGVudCBvciAiIgogICAgICAgICAgICB1c2FnZSA9IHJlc3BvbnNlLnVzYWdlCgogICAgICAgICAgICAjIFBhcnNlIHRoZSBzdHJ1Y3R1cmVkIEpTT04KICAgICAgICAgICAgcGFyc2VkID0gTm9uZQogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICBwYXJzZWQgPSBqc29uLmxvYWRzKGNvbnRlbnQpCiAgICAgICAgICAgIGV4Y2VwdCAoanNvbi5KU09ORGVjb2RlRXJyb3IsIFR5cGVFcnJvcikgYXMgZToKICAgICAgICAgICAgICAgIGxvZ2dlci53YXJuaW5nKCJGYWlsZWQgdG8gcGFyc2Ugc3RydWN0dXJlZCByZXNwb25zZTogJXMiLCBlKQogICAgICAgICAgICAgICAgIyBGYWxsYmFjazogdHJ5IGV4dHJhY3RpbmcgSlNPTiBvYmplY3QKICAgICAgICAgICAgICAgIHBhcnNlZCA9IHNlbGYuX2V4dHJhY3RfanNvbihjb250ZW50KQoKICAgICAgICAgICAgIyBFeHRyYWN0IGNpdGF0aW9ucyBpZiBhdmFpbGFibGUKICAgICAgICAgICAgY2l0YXRpb25zID0gW10KICAgICAgICAgICAgaWYgaGFzYXR0cihyZXNwb25zZSwgJ2NpdGF0aW9ucycpIGFuZCByZXNwb25zZS5jaXRhdGlvbnM6CiAgICAgICAgICAgICAgICBjaXRhdGlvbnMgPSByZXNwb25zZS5jaXRhdGlvbnMKCiAgICAgICAgICAgIGxvZ2dlci5pbmZvKAog' +
'ICAgICAgICAgICAgICAgIltwcGx4XSBtb2RlbD0lcyB0b2tlbnM9KCVkIHByb21wdCwgJWQgY29tcGxldGlvbikgcGFyc2VkPSVzIiwKICAgICAgICAgICAgICAgIHVzZV9tb2RlbCwKICAgICAgICAgICAgICAgIHVzYWdlLnByb21wdF90b2tlbnMgaWYgdXNhZ2UgZWxzZSAwLAogICAgICAgICAgICAgICAgdXNhZ2UuY29tcGxldGlvbl90b2tlbnMgaWYgdXNhZ2UgZWxzZSAwLAogICAgICAgICAgICAgICAgInllcyIgaWYgcGFyc2VkIGVsc2UgIm5vIiwKICAgICAgICAgICAgKQoKICAgICAgICAgICAgcmV0dXJuIFBlcnBsZXhpdHlSZXNwb25zZSgKICAgICAgICAgICAgICAgIGNvbnRlbnQ9Y29udGVudCwKICAgICAgICAgICAgICAgIHBhcnNlZF9qc29uPXBhcnNlZCwKICAgICAgICAgICAgICAgIHByb21wdF90b2tlbnM9dXNhZ2UucHJvbXB0X3Rva2VucyBpZiB1c2FnZSBlbHNlIDAsCiAgICAgICAgICAgICAgICBjb21wbGV0aW9uX3Rva2Vucz11c2FnZS5jb21wbGV0aW9uX3Rva2VucyBpZiB1c2FnZSBlbHNlIDAsCiAgICAgICAgICAgICAgICBtb2RlbD11c2VfbW9kZWwsCiAgICAgICAgICAgICAgICBjaXRhdGlvbnM9Y2l0YXRpb25zLAogICAgICAgICAgICApCgogICAgICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICAgICAgbG9nZ2VyLmVycm9yKCJbcHBseF0gQVBJIGNhbGwgZmFpbGVkOiAlcyIsIGUpCiAgICAgICAgICAgIHJhaXNlCgogICAgZGVmIGNoYXRfcGxhaW4oCiAgICAgICAgc2VsZiwKICAgICAgICBzeXN0ZW1fcHJvbXB0OiBzdHIsCiAgICAgICAgdXNlcl9wcm9tcHQ6IHN0ciwKICAgICAgICBtb2RlbDogc3RyID0gTm9uZSwKICAgICAgICB0ZW1wZXJhdHVyZTogZmxvYXQgPSAwLjIsCiAgICAgICAgbWF4X3Rva2VuczogaW50ID0gNDA5NiwKICAgICkgLT4gUGVycGxleGl0eVJlc3BvbnNlOgogICAgICAgICIiIgogICAgICAgIFNlbmQgYSBwbGFpbiBjaGF0IHJlcXVlc3Qgd2l0aG91dCBzdHJ1Y3R1cmVkIG91dHB1dC4KICAgICAgICBVc2VkIGZvciBzdWJzdGl0dXRpb24gZmFsbGJhY2sgcXVlcmllcyB3aGVyZSBzY2hlbWEKICAgICAgICBjb21wbGlhbmNlIGlzIGxlc3MgY3JpdGljYWwuCiAgICAgICAgIiIiCiAgICAgICAgdXNlX21vZGVsID0gbW9kZWwgb3Igc2VsZi5tb2RlbAoKICAgICAgICBtZXNzYWdlcyA9IFsKICAgICAgICAgICAgeyJyb2xlIjogInN5c3RlbSIsICJjb250ZW50Ijogc3lzdGVtX3Byb21wdH0sCiAgICAgICAgICAgIHsicm9sZSI6ICJ1c2VyIiwgImNvbnRlbnQiOiB1c2VyX3Byb21wdH0sCiAgICAgICAgXQoKICAgICAgICB0cnk6CiAgICAgICAgICAgIHJlc3BvbnNlID0gc2VsZi5fY2xpZW50LmNoYXQuY29tcGxldGlvbnMuY3JlYXRlKAogICAgICAgICAgICAgICAgbW9kZWw9dXNlX21vZGVsLAogICAgICAgICAgICAgICAgbWVzc2FnZXM9bWVzc2FnZXMsCiAgICAgICAgICAgICAgICB0ZW1wZXJhdHVyZT10ZW1wZXJhdHVyZSwKICAgICAgICAgICAgICAgIG1heF90b2tlbnM9bWF4X3Rva2VucywKICAgICAgICAgICAgKQoKICAgICAgICAgICAgY2hvaWNlID0gcmVzcG9uc2UuY2hvaWNlc1swXQogICAgICAgICAgICBjb250ZW50ID0gY2hvaWNlLm1lc3NhZ2UuY29udGVudCBvciAiIgogICAgICAgICAgICB1c2FnZSA9IHJlc3BvbnNlLnVzYWdlCgogICAgICAgICAgICAjIFRyeSB0byBwYXJzZSBhcyBKU09OIGFueXdheQogICAgICAgICAgICBwYXJzZWQgPSBzZWxmLl9leHRyYWN0X2pzb24oY29udGVudCkKCiAgICAgICAgICAgIGNpdGF0aW9ucyA9IFtdCiAgICAgICAgICAgIGlmIGhhc2F0dHIocmVzcG9uc2UsICdjaXRhdGlvbnMnKSBhbmQgcmVzcG9uc2UuY2l0YXRpb25zOgogICAgICAgICAgICAgICAgY2l0YXRpb25zID0gcmVzcG9uc2UuY2l0YXRpb25zCgogICAgICAgICAgICByZXR1cm4gUGVycGxleGl0eVJlc3BvbnNlKAogICAgICAgICAgICAgICAgY29udGVudD1jb250ZW50LAogICAgICAgICAgICAgICAgcGFyc2VkX2pzb249cGFyc2VkLAogICAgICAgICAgICAgICAgcHJvbXB0X3Rva2Vucz11c2FnZS5wcm9tcHRfdG9rZW5zIGlmIHVzYWdlIGVsc2UgMCwKICAgICAgICAgICAgICAgIGNvbXBsZXRpb25fdG9rZW5zPXVzYWdlLmNvbXBsZXRpb25fdG9rZW5zIGlmIHVzYWdlIGVsc2UgMCwKICAgICAgICAgICAgICAgIG1vZGVsPXVzZV9tb2RlbCwKICAgICAgICAgICAgICAgIGNpdGF0aW9ucz1jaXRhdGlvbnMsCiAgICAgICAgICAgICkKCiAgICAgICAgZXhjZXB0IEV4Y2Vw' +
'dGlvbiBhcyBlOgogICAgICAgICAgICBsb2dnZXIuZXJyb3IoIltwcGx4XSBQbGFpbiBjaGF0IGZhaWxlZDogJXMiLCBlKQogICAgICAgICAgICByYWlzZQoKICAgIGFzeW5jIGRlZiBhY2hhdF9zdHJ1Y3R1cmVkKHNlbGYsICphcmdzLCAqKmt3YXJncykgLT4gUGVycGxleGl0eVJlc3BvbnNlOgogICAgICAgICIiIkFzeW5jIHdyYXBwZXIg4oCUIHJ1bnMgc3RydWN0dXJlZCBjaGF0IGluIHRocmVhZCBleGVjdXRvci4iIiIKICAgICAgICBsb29wID0gYXN5bmNpby5nZXRfZXZlbnRfbG9vcCgpCiAgICAgICAgcmV0dXJuIGF3YWl0IGxvb3AucnVuX2luX2V4ZWN1dG9yKAogICAgICAgICAgICBOb25lLCBsYW1iZGE6IHNlbGYuY2hhdF9zdHJ1Y3R1cmVkKCphcmdzLCAqKmt3YXJncykKICAgICAgICApCgogICAgYXN5bmMgZGVmIGFjaGF0X3BsYWluKHNlbGYsICphcmdzLCAqKmt3YXJncykgLT4gUGVycGxleGl0eVJlc3BvbnNlOgogICAgICAgICIiIkFzeW5jIHdyYXBwZXIg4oCUIHJ1bnMgcGxhaW4gY2hhdCBpbiB0aHJlYWQgZXhlY3V0b3IuIiIiCiAgICAgICAgbG9vcCA9IGFzeW5jaW8uZ2V0X2V2ZW50X2xvb3AoKQogICAgICAgIHJldHVybiBhd2FpdCBsb29wLnJ1bl9pbl9leGVjdXRvcigKICAgICAgICAgICAgTm9uZSwgbGFtYmRhOiBzZWxmLmNoYXRfcGxhaW4oKmFyZ3MsICoqa3dhcmdzKQogICAgICAgICkKCiAgICBAc3RhdGljbWV0aG9kCiAgICBkZWYgX2V4dHJhY3RfanNvbih0ZXh0OiBzdHIpIC0+IE9wdGlvbmFsW2RpY3RdOgogICAgICAgICIiIkJlc3QtZWZmb3J0IEpTT04gZXh0cmFjdGlvbiBmcm9tIHBvc3NpYmx5IG5vaXN5IHRleHQuIiIiCiAgICAgICAgIyBEaXJlY3QgcGFyc2UKICAgICAgICB0cnk6CiAgICAgICAgICAgIHJldHVybiBqc29uLmxvYWRzKHRleHQuc3RyaXAoKSkKICAgICAgICBleGNlcHQgKGpzb24uSlNPTkRlY29kZUVycm9yLCBUeXBlRXJyb3IpOgogICAgICAgICAgICBwYXNzCgogICAgICAgICMgTWFya2Rvd24gZmVuY2VzCiAgICAgICAgaW1wb3J0IHJlCiAgICAgICAgZmVuY2VfbWF0Y2ggPSByZS5zZWFyY2gocidgYGAoPzpqc29uKT9ccyooW1xzXFNdKj8pYGBgJywgdGV4dCkKICAgICAgICBpZiBmZW5jZV9tYXRjaDoKICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgcmV0dXJuIGpzb24ubG9hZHMoZmVuY2VfbWF0Y2guZ3JvdXAoMSkuc3RyaXAoKSkKICAgICAgICAgICAgZXhjZXB0IChqc29uLkpTT05EZWNvZGVFcnJvciwgVHlwZUVycm9yKToKICAgICAgICAgICAgICAgIHBhc3MKCiAgICAgICAgIyBCcmFjZSBjb3VudGluZwogICAgICAgIHN0YXJ0ID0gdGV4dC5maW5kKCd7JykKICAgICAgICBpZiBzdGFydCA9PSAtMToKICAgICAgICAgICAgcmV0dXJuIE5vbmUKCiAgICAgICAgZGVwdGggPSAwCiAgICAgICAgaW5fc3RyaW5nID0gRmFsc2UKICAgICAgICBlc2NhcGVfbmV4dCA9IEZhbHNlCiAgICAgICAgZm9yIGkgaW4gcmFuZ2Uoc3RhcnQsIGxlbih0ZXh0KSk6CiAgICAgICAgICAgIGNoID0gdGV4dFtpXQogICAgICAgICAgICBpZiBlc2NhcGVfbmV4dDoKICAgICAgICAgICAgICAgIGVzY2FwZV9uZXh0ID0gRmFsc2UKICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgIGlmIGNoID09ICdcXCcgYW5kIGluX3N0cmluZzoKICAgICAgICAgICAgICAgIGVzY2FwZV9uZXh0ID0gVHJ1ZQogICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgaWYgY2ggPT0gJyInOgogICAgICAgICAgICAgICAgaW5fc3RyaW5nID0gbm90IGluX3N0cmluZwogICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgaWYgaW5fc3RyaW5nOgogICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgaWYgY2ggPT0gJ3snOgogICAgICAgICAgICAgICAgZGVwdGggKz0gMQogICAgICAgICAgICBlbGlmIGNoID09ICd9JzoKICAgICAgICAgICAgICAgIGRlcHRoIC09IDEKICAgICAgICAgICAgICAgIGlmIGRlcHRoID09IDA6CiAgICAgICAgICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgICAgICAgICByZXR1cm4ganNvbi5sb2Fkcyh0ZXh0W3N0YXJ0OmkrMV0pCiAgICAgICAgICAgICAgICAgICAgZXhjZXB0IChqc29uLkpTT05EZWNvZGVFcnJvciwgVHlwZUVycm9yKToKICAgICAgICAgICAgICAgICAgICAgICAgcmV0dXJuIE5vbmUKCiAgICAgICAgIyBJZiB3ZSBnZXQgaGVyZSwgSlNPTiB3YXMgdHJ1bmNhdGVkIChkZXB0aCA+IDApLiBUcnkgdG8gcmVwYWlyLgogICAg' +
'ICAgIHJldHVybiBQZXJwbGV4aXR5Q2xpZW50Ll9yZXBhaXJfdHJ1bmNhdGVkX2pzb24odGV4dFtzdGFydDpdKQoKICAgIEBzdGF0aWNtZXRob2QKICAgIGRlZiBfcmVwYWlyX3RydW5jYXRlZF9qc29uKHRleHQ6IHN0cikgLT4gT3B0aW9uYWxbZGljdF06CiAgICAgICAgIiIiCiAgICAgICAgQXR0ZW1wdCB0byByZXBhaXIgdHJ1bmNhdGVkIEpTT04gZnJvbSBtYXhfdG9rZW5zIGN1dG9mZi4KICAgICAgICBTdHJhdGVneTogZmluZCB0aGUgbGFzdCBjb21wbGV0ZSBhcnJheSBlbGVtZW50IGluICJjYXJkcyIsCiAgICAgICAgdGhlbiBjbG9zZSBhbGwgb3BlbiBicmFja2V0cy9icmFjZXMuCiAgICAgICAgIiIiCiAgICAgICAgbG9nZ2VyLmluZm8oIkF0dGVtcHRpbmcgdHJ1bmNhdGVkIEpTT04gcmVwYWlyICglZCBjaGFycykuLi4iLCBsZW4odGV4dCkpCgogICAgICAgICMgRmluZCB0aGUgbGFzdCBjb21wbGV0ZSBjYXJkIG9iamVjdCAoZW5kcyB3aXRoIH0pIGluIHRoZSBjYXJkcyBhcnJheQogICAgICAgICMgV2FsayBiYWNrd2FyZHMgdG8gZmluZCB0aGUgbGFzdCBjbG9zaW5nIGJyYWNlIHRoYXQgZW5kcyBhIHZhbGlkIGNhcmQKICAgICAgICBsYXN0X2dvb2QgPSAtMQogICAgICAgIGRlcHRoID0gMAogICAgICAgIGluX3N0cmluZyA9IEZhbHNlCiAgICAgICAgZXNjYXBlX25leHQgPSBGYWxzZQoKICAgICAgICBmb3IgaSBpbiByYW5nZShsZW4odGV4dCkpOgogICAgICAgICAgICBjaCA9IHRleHRbaV0KICAgICAgICAgICAgaWYgZXNjYXBlX25leHQ6CiAgICAgICAgICAgICAgICBlc2NhcGVfbmV4dCA9IEZhbHNlCiAgICAgICAgICAgICAgICBjb250aW51ZQogICAgICAgICAgICBpZiBjaCA9PSAnXFwnIGFuZCBpbl9zdHJpbmc6CiAgICAgICAgICAgICAgICBlc2NhcGVfbmV4dCA9IFRydWUKICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgIGlmIGNoID09ICciJzoKICAgICAgICAgICAgICAgIGluX3N0cmluZyA9IG5vdCBpbl9zdHJpbmcKICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgIGlmIGluX3N0cmluZzoKICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgIGlmIGNoID09ICd7JzoKICAgICAgICAgICAgICAgIGRlcHRoICs9IDEKICAgICAgICAgICAgZWxpZiBjaCA9PSAnfSc6CiAgICAgICAgICAgICAgICBkZXB0aCAtPSAxCiAgICAgICAgICAgICAgICBpZiBkZXB0aCA+PSAxOgogICAgICAgICAgICAgICAgICAgICMgVGhpcyBjbG9zZXMgYSBuZXN0ZWQgb2JqZWN0IChsaWtlIGEgY2FyZCkg4oCUIG1hcmsgYXMgcG90ZW50aWFsIGN1dG9mZgogICAgICAgICAgICAgICAgICAgIGxhc3RfZ29vZCA9IGkKCiAgICAgICAgaWYgbGFzdF9nb29kID09IC0xOgogICAgICAgICAgICByZXR1cm4gTm9uZQoKICAgICAgICAjIFRha2UgdGV4dCB1cCB0byB0aGUgbGFzdCBjb21wbGV0ZSBuZXN0ZWQgb2JqZWN0CiAgICAgICAgdHJ1bmNhdGVkID0gdGV4dFs6bGFzdF9nb29kICsgMV0KCiAgICAgICAgIyBOb3cgZmlndXJlIG91dCB3aGF0IHdlIG5lZWQgdG8gY2xvc2UuCiAgICAgICAgIyBSZS1zY2FuIHRvIGdldCB0aGUgY2xvc2luZyBzZXF1ZW5jZS4KICAgICAgICBjbG9zZV9kZXB0aCA9IDAKICAgICAgICBpbl9hcnIgPSAwCiAgICAgICAgaW5fc3RyID0gRmFsc2UKICAgICAgICBlc2MgPSBGYWxzZQogICAgICAgIGZvciBjaCBpbiB0cnVuY2F0ZWQ6CiAgICAgICAgICAgIGlmIGVzYzoKICAgICAgICAgICAgICAgIGVzYyA9IEZhbHNlCiAgICAgICAgICAgICAgICBjb250aW51ZQogICAgICAgICAgICBpZiBjaCA9PSAnXFwnIGFuZCBpbl9zdHI6CiAgICAgICAgICAgICAgICBlc2MgPSBUcnVlCiAgICAgICAgICAgICAgICBjb250aW51ZQogICAgICAgICAgICBpZiBjaCA9PSAnIic6CiAgICAgICAgICAgICAgICBpbl9zdHIgPSBub3QgaW5fc3RyCiAgICAgICAgICAgICAgICBjb250aW51ZQogICAgICAgICAgICBpZiBpbl9zdHI6CiAgICAgICAgICAgICAgICBjb250aW51ZQogICAgICAgICAgICBpZiBjaCA9PSAneyc6CiAgICAgICAgICAgICAgICBjbG9zZV9kZXB0aCArPSAxCiAgICAgICAgICAgIGVsaWYgY2ggPT0gJ30nOgogICAgICAgICAgICAgICAgY2xvc2VfZGVwdGggLT0gMQogICAgICAgICAgICBlbGlmIGNoID09ICdbJzoKICAgICAgICAgICAgICAgIGluX2FyciArPSAxCiAgICAgICAgICAgIGVsaWYgY2ggPT0gJ10nOgogICAgICAgICAgICAgICAgaW5fYXJyIC09IDEKCiAgICAgICAgIyBCdWls' +
'ZCBjbG9zaW5nIHNlcXVlbmNlCiAgICAgICAgc3VmZml4ID0gJ10nICogaW5fYXJyICsgJ30nICogY2xvc2VfZGVwdGgKICAgICAgICByZXBhaXJlZCA9IHRydW5jYXRlZCArIHN1ZmZpeAoKICAgICAgICB0cnk6CiAgICAgICAgICAgIHJlc3VsdCA9IGpzb24ubG9hZHMocmVwYWlyZWQpCiAgICAgICAgICAgIGNhcmRfY291bnQgPSBsZW4ocmVzdWx0LmdldCgnY2FyZHMnLCBbXSkpCiAgICAgICAgICAgIGxvZ2dlci5pbmZvKCJKU09OIHJlcGFpciBzdWNjZWVkZWQ6IHJlY292ZXJlZCAlZCBjYXJkcyIsIGNhcmRfY291bnQpCiAgICAgICAgICAgIHJldHVybiByZXN1bHQKICAgICAgICBleGNlcHQgKGpzb24uSlNPTkRlY29kZUVycm9yLCBUeXBlRXJyb3IpIGFzIGU6CiAgICAgICAgICAgIGxvZ2dlci53YXJuaW5nKCJKU09OIHJlcGFpciBmYWlsZWQ6ICVzIiwgZSkKICAgICAgICAgICAgcmV0dXJuIE5vbmUKCiAgICBkZWYgY2hlY2tfc3RhdHVzKHNlbGYpIC0+IGRpY3Q6CiAgICAgICAgIiIiUXVpY2sgaGVhbHRoIGNoZWNrIOKAlCB0cnkgYSBtaW5pbWFsIEFQSSBjYWxsLiIiIgogICAgICAgIHRyeToKICAgICAgICAgICAgcmVzcG9uc2UgPSBzZWxmLl9jbGllbnQuY2hhdC5jb21wbGV0aW9ucy5jcmVhdGUoCiAgICAgICAgICAgICAgICBtb2RlbD1zZWxmLm1vZGVsLAogICAgICAgICAgICAgICAgbWVzc2FnZXM9WwogICAgICAgICAgICAgICAgICAgIHsicm9sZSI6ICJzeXN0ZW0iLCAiY29udGVudCI6ICJSZXBseSB3aXRoIHRoZSB3b3JkIE9LLiJ9LAogICAgICAgICAgICAgICAgICAgIHsicm9sZSI6ICJ1c2VyIiwgImNvbnRlbnQiOiAiU3RhdHVzIGNoZWNrLiJ9LAogICAgICAgICAgICAgICAgXSwKICAgICAgICAgICAgICAgIG1heF90b2tlbnM9MTAsCiAgICAgICAgICAgICkKICAgICAgICAgICAgcmV0dXJuIHsKICAgICAgICAgICAgICAgICJjb25uZWN0ZWQiOiBUcnVlLAogICAgICAgICAgICAgICAgIm1vZGVsIjogc2VsZi5tb2RlbCwKICAgICAgICAgICAgICAgICJyZXNwb25zZSI6IHJlc3BvbnNlLmNob2ljZXNbMF0ubWVzc2FnZS5jb250ZW50LAogICAgICAgICAgICB9CiAgICAgICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgICAgICByZXR1cm4gewogICAgICAgICAgICAgICAgImNvbm5lY3RlZCI6IEZhbHNlLAogICAgICAgICAgICAgICAgIm1vZGVsIjogc2VsZi5tb2RlbCwKICAgICAgICAgICAgICAgICJlcnJvciI6IHN0cihlKSwKICAgICAgICAgICAgfQo='
'dGlvbiBhcyBlOgogICAgICAgICAgICBsb2dnZXIuZXJyb3IoIltwcGx4XSBQbGFpbiBjaGF0IGZhaWxlZDogJXMiLCBlKQogICAgICAgICAgICByYWlzZQoKICAgIGFzeW5jIGRlZiBhY2hhdF9zdHJ1Y3R1cmVkKHNlbGYsICphcmdzLCAqKmt3YXJncykgLT4gUGVycGxleGl0eVJlc3BvbnNlOgogICAgICAgICIiIkFzeW5jIHdyYXBwZXIg4oCUIHJ1bnMgc3RydWN0dXJlZCBjaGF0IGluIHRocmVhZCBleGVjdXRvci4iIiIKICAgICAgICBsb29wID0gYXN5bmNpby5nZXRfZXZlbnRfbG9vcCgpCiAgICAgICAgcmV0dXJuIGF3YWl0IGxvb3AucnVuX2luX2V4ZWN1dG9yKAogICAgICAgICAgICBOb25lLCBsYW1iZGE6IHNlbGYuY2hhdF9zdHJ1Y3R1cmVkKCphcmdzLCAqKmt3YXJncykKICAgICAgICApCgogICAgYXN5bmMgZGVmIGFjaGF0X3BsYWluKHNlbGYsICphcmdzLCAqKmt3YXJncykgLT4gUGVycGxleGl0eVJlc3BvbnNlOgogICAgICAgICIiIkFzeW5jIHdyYXBwZXIg4oCUIHJ1bnMgcGxhaW4gY2hhdCBpbiB0aHJlYWQgZXhlY3V0b3IuIiIiCiAgICAgICAgbG9vcCA9IGFzeW5jaW8uZ2V0X2V2ZW50X2xvb3AoKQogICAgICAgIHJldHVybiBhd2FpdCBsb29wLnJ1bl9pbl9leGVjdXRvcigKICAgICAgICAgICAgTm9uZSwgbGFtYmRhOiBzZWxmLmNoYXRfcGxhaW4oKmFyZ3MsICoqa3dhcmdzKQogICAgICAgICkKCiAgICBAc3RhdGljbWV0aG9kCiAgICBkZWYgX2V4dHJhY3RfanNvbih0ZXh0OiBzdHIpIC0+IE9wdGlvbmFsW2RpY3RdOgogICAgICAgICIiIkJlc3QtZWZmb3J0IEpTT04gZXh0cmFjdGlvbiBmcm9tIHBvc3NpYmx5IG5vaXN5IHRleHQuIiIiCiAgICAgICAgIyBEaXJlY3QgcGFyc2UKICAgICAgICB0cnk6CiAgICAgICAgICAgIHJldHVybiBqc29uLmxvYWRzKHRleHQuc3RyaXAoKSkKICAgICAgICBleGNlcHQgKGpzb24uSlNPTkRlY29kZUVycm9yLCBUeXBlRXJyb3IpOgogICAgICAgICAgICBwYXNzCgogICAgICAgICMgTWFya2Rvd24gZmVuY2VzCiAgICAgICAgaW1wb3J0IHJlCiAgICAgICAgZmVuY2VfbWF0Y2ggPSByZS5zZWFyY2gocidgYGAoPzpqc29uKT9ccyooW1xzXFNdKj8pYGBgJywgdGV4dCkKICAgICAgICBpZiBmZW5jZV9tYXRjaDoKICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgcmV0dXJuIGpzb24ubG9hZHMoZmVuY2VfbWF0Y2guZ3JvdXAoMSkuc3RyaXAoKSkKICAgICAgICAgICAgZXhjZXB0IChqc29uLkpTT05EZWNvZGVFcnJvciwgVHlwZUVycm9yKToKICAgICAgICAgICAgICAgIHBhc3MKCiAgICAgICAgIyBCcmFjZSBjb3VudGluZwogICAgICAgIHN0YXJ0ID0gdGV4dC5maW5kKCd7JykKICAgICAgICBpZiBzdGFydCA9PSAtMToKICAgICAgICAgICAgcmV0dXJuIE5vbmUKCiAgICAgICAgZGVwdGggPSAwCiAgICAgICAgaW5fc3RyaW5nID0gRmFsc2UKICAgICAgICBlc2NhcGVfbmV4dCA9IEZhbHNlCiAgICAgICAgZm9yIGkgaW4gcmFuZ2Uoc3RhcnQsIGxlbih0ZXh0KSk6CiAgICAgICAgICAgIGNoID0gdGV4dFtpXQogICAgICAgICAgICBpZiBlc2NhcGVfbmV4dDoKICAgICAgICAgICAgICAgIGVzY2FwZV9uZXh0ID0gRmFsc2UKICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgIGlmIGNoID09ICdcXCcgYW5kIGluX3N0cmluZzoKICAgICAgICAgICAgICAgIGVzY2FwZV9uZXh0ID0gVHJ1ZQogICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgaWYgY2ggPT0gJyInOgogICAgICAgICAgICAgICAgaW5fc3RyaW5nID0gbm90IGluX3N0cmluZwogICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgaWYgaW5fc3RyaW5nOgogICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgaWYgY2ggPT0gJ3snOgogICAgICAgICAgICAgICAgZGVwdGggKz0gMQogICAgICAgICAgICBlbGlmIGNoID09ICd9JzoKICAgICAgICAgICAgICAgIGRlcHRoIC09IDEKICAgICAgICAgICAgICAgIGlmIGRlcHRoID09IDA6CiAgICAgICAgICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgICAgICAgICByZXR1cm4ganNvbi5sb2Fkcyh0ZXh0W3N0YXJ0OmkrMV0pCiAgICAgICAgICAgICAgICAgICAgZXhjZXB0IChqc29uLkpTT05EZWNvZGVFcnJvciwgVHlwZUVycm9yKToKICAgICAgICAgICAgICAgICAgICAgICAgcmV0dXJuIE5vbmUKICAgICAgICByZXR1cm4gTm9uZQoKICAgIGRlZiBjaGVja19zdGF0dXMoc2VsZikgLT4gZGljdDoKICAgICAgICAiIiJRdWljayBoZWFs' +
'dGggY2hlY2sg4oCUIHRyeSBhIG1pbmltYWwgQVBJIGNhbGwuIiIiCiAgICAgICAgdHJ5OgogICAgICAgICAgICByZXNwb25zZSA9IHNlbGYuX2NsaWVudC5jaGF0LmNvbXBsZXRpb25zLmNyZWF0ZSgKICAgICAgICAgICAgICAgIG1vZGVsPXNlbGYubW9kZWwsCiAgICAgICAgICAgICAgICBtZXNzYWdlcz1bCiAgICAgICAgICAgICAgICAgICAgeyJyb2xlIjogInN5c3RlbSIsICJjb250ZW50IjogIlJlcGx5IHdpdGggdGhlIHdvcmQgT0suIn0sCiAgICAgICAgICAgICAgICAgICAgeyJyb2xlIjogInVzZXIiLCAiY29udGVudCI6ICJTdGF0dXMgY2hlY2suIn0sCiAgICAgICAgICAgICAgICBdLAogICAgICAgICAgICAgICAgbWF4X3Rva2Vucz0xMCwKICAgICAgICAgICAgKQogICAgICAgICAgICByZXR1cm4gewogICAgICAgICAgICAgICAgImNvbm5lY3RlZCI6IFRydWUsCiAgICAgICAgICAgICAgICAibW9kZWwiOiBzZWxmLm1vZGVsLAogICAgICAgICAgICAgICAgInJlc3BvbnNlIjogcmVzcG9uc2UuY2hvaWNlc1swXS5tZXNzYWdlLmNvbnRlbnQsCiAgICAgICAgICAgIH0KICAgICAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgICAgIHJldHVybiB7CiAgICAgICAgICAgICAgICAiY29ubmVjdGVkIjogRmFsc2UsCiAgICAgICAgICAgICAgICAibW9kZWwiOiBzZWxmLm1vZGVsLAogICAgICAgICAgICAgICAgImVycm9yIjogc3RyKGUpLAogICAgICAgICAgICB9Cg=='
Install-B64 -R 'coach\clients\perplexity_client.py' -B $b
# coach/coach_service.py (11,542 bytes)
# coach/coach_service.py (11,183 bytes)
$b = 'IiIiCkNvbW1hbmRlciBBSSBMYWIg4oCUIENvYWNoIFNlcnZpY2UK4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCk1haW4gb3JjaGVzdHJhdG9yOiBsb2FkcyBkZWNrIHJlcG9ydHMsIGlkZW50aWZpZXMgdW5kZXJwZXJmb3JtZXJzLApmaW5kcyByZXBsYWNlbWVudCBjYW5kaWRhdGVzIHZpYSBlbWJlZGRpbmdzLCBidWlsZHMgcHJvbXB0cywKY2FsbHMgdGhlIExMTSwgcGFyc2VzIHJlc3BvbnNlcywgYW5kIHBlcnNpc3RzIGNvYWNoaW5nIHNlc3Npb25zLgoiIiIKCmltcG9ydCBqc29uCmltcG9ydCBsb2dnaW5nCmltcG9ydCB1dWlkCmZyb20gZGF0ZXRpbWUgaW1wb3J0IGRhdGV0aW1lLCB0aW1lem9uZQpmcm9tIHBhdGhsaWIgaW1wb3J0IFBhdGgKZnJvbSB0eXBpbmcgaW1wb3J0IE9wdGlvbmFsLCBMaXN0LCBEaWN0Cgpmcm9tIC5jb25maWcgaW1wb3J0ICgKICAgIERFQ0tfUkVQT1JUU19ESVIsIENPQUNIX1NFU1NJT05TX0RJUiwKICAgIE1BWF9VTkRFUlBFUkZPUk1FUlMsIE1BWF9DQU5ESURBVEVTX1BFUl9VTkRFUlBFUkZPUk1FUiwKICAgIFVOREVSUEVSRk9STUVSX0lNUEFDVF9USFJFU0hPTEQsIGVuc3VyZV9kaXJzLAopCmZyb20gLm1vZGVscyBpbXBvcnQgKAogICAgRGVja1JlcG9ydCwgQ29hY2hHb2FscywgQ29hY2hTZXNzaW9uLAogICAgU3VnZ2VzdGVkQ3V0LCBTdWdnZXN0ZWRBZGQsIENvYWNoU3RhdHVzLAopCmZyb20gLmxsbV9jbGllbnQgaW1wb3J0IExNU3R1ZGlvQ2xpZW50CmZyb20gLmVtYmVkZGluZ3MgaW1wb3J0IE1UR0VtYmVkZGluZ0luZGV4CmZyb20gLnByb21wdF90ZW1wbGF0ZSBpbXBvcnQgYnVpbGRfc3lzdGVtX3Byb21wdCwgYnVpbGRfdXNlcl9wcm9tcHQKCmxvZ2dlciA9IGxvZ2dpbmcuZ2V0TG9nZ2VyKCJjb2FjaC5zZXJ2aWNlIikKCgpjbGFzcyBDb2FjaFNlcnZpY2U6CiAgICAiIiIKICAgIERlY2sgY29hY2hpbmcgc2VydmljZSBwb3dlcmVkIGJ5IGxvY2FsIExMTSBhbmQgY2FyZCBlbWJlZGRpbmdzLgoKICAgIFVzYWdlOgogICAgICAgIGVtYmVkZGluZ3MgPSBNVEdFbWJlZGRpbmdJbmRleCgpCiAgICAgICAgZW1iZWRkaW5ncy5sb2FkKCkKICAgICAgICBsbG0gPSBMTVN0dWRpb0NsaWVudCgpCiAgICAgICAgY29hY2ggPSBDb2FjaFNlcnZpY2UobGxtLCBlbWJlZGRpbmdzKQogICAgICAgIHNlc3Npb24gPSBhd2FpdCBjb2FjaC5ydW5fY29hY2hpbmdfc2Vzc2lvbigiZWRnYXItbWFya292IikKICAgICIiIgoKICAgIGRlZiBfX2luaXRfXyhzZWxmLCBsbG06IExNU3R1ZGlvQ2xpZW50LCBlbWJlZGRpbmdzOiBNVEdFbWJlZGRpbmdJbmRleCk6CiAgICAgICAgc2VsZi5sbG0gPSBsbG0KICAgICAgICBzZWxmLmVtYmVkZGluZ3MgPSBlbWJlZGRpbmdzCiAgICAgICAgZW5zdXJlX2RpcnMoKQoKICAgICMg4pSA4pSAIERlY2sgUmVwb3J0IEkvTyDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKCiAgICBkZWYgbG9hZF9kZWNrX3JlcG9ydChzZWxmLCBkZWNrX2lkOiBzdHIpIC0+IE9wdGlvbmFsW0RlY2tSZXBvcnRdOgogICAgICAgICIiIkxvYWQgYSBkZWNrIHJlcG9ydCBmcm9tIGRpc2suIiIiCiAgICAgICAgIyBUcnkgZXhhY3QgZmlsZW5hbWUgZmlyc3QsIHRoZW4gY2FzZS1pbnNlbnNpdGl2ZSBzZWFyY2gKICAgICAgICByZXBvcnRfcGF0aCA9IERFQ0tfUkVQT1JUU19ESVIgLyBmIntkZWNrX2lkfS5qc29uIgogICAgICAgIGlmIG5vdCByZXBvcnRfcGF0aC5leGlzdHMoKToKICAgICAgICAgICAgIyBUcnkgY2FzZS1pbnNlbnNpdGl2ZSBtYXRjaAogICAgICAgICAgICBmb3IgZiBpbiBERUNLX1JFUE9SVFNfRElSLmdsb2IoIiouanNvbiIpOgogICAgICAgICAgICAgICAgaWYgZi5zdGVtLmxvd2VyKCkgPT0gZGVja19pZC5sb3dlcigpOgogICAgICAgICAgICAgICAgICAgIHJlcG9ydF9wYXRoID0gZgogICAgICAgICAgICAgICAgICAgIGJyZWFrCgogICAgICAgIGlmIG5vdCByZXBvcnRfcGF0aC5leGlzdHMoKToKICAgICAgICAgICAgbG9nZ2VyLndhcm5pbmcoIkRlY2sgcmVwb3J0IG5vdCBmb3VuZDogJXMiLCBkZWNrX2lkKQogICAgICAgICAgICByZXR1cm4gTm9uZQoKICAgICAgICB0cnk6CiAgICAgICAgICAgIHdpdGggb3Bl' +
'bihyZXBvcnRfcGF0aCwgInIiKSBhcyBmOgogICAgICAgICAgICAgICAgZGF0YSA9IGpzb24ubG9hZChmKQogICAgICAgICAgICByZXR1cm4gRGVja1JlcG9ydCgqKmRhdGEpCiAgICAgICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgICAgICBsb2dnZXIuZXJyb3IoIkZhaWxlZCB0byBsb2FkIGRlY2sgcmVwb3J0ICVzOiAlcyIsIGRlY2tfaWQsIGUpCiAgICAgICAgICAgIHJldHVybiBOb25lCgogICAgZGVmIGxpc3RfZGVja19yZXBvcnRzKHNlbGYpIC0+IExpc3Rbc3RyXToKICAgICAgICAiIiJMaXN0IGFsbCBhdmFpbGFibGUgZGVjayByZXBvcnQgSURzLiIiIgogICAgICAgIGlmIG5vdCBERUNLX1JFUE9SVFNfRElSLmV4aXN0cygpOgogICAgICAgICAgICByZXR1cm4gW10KICAgICAgICByZXR1cm4gW2Yuc3RlbSBmb3IgZiBpbiBERUNLX1JFUE9SVFNfRElSLmdsb2IoIiouanNvbiIpXQoKICAgICMg4pSA4pSAIENvYWNoIFNlc3Npb24gSS9PIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAoKICAgIGRlZiBzYXZlX3Nlc3Npb24oc2VsZiwgc2Vzc2lvbjogQ29hY2hTZXNzaW9uKSAtPiBQYXRoOgogICAgICAgICIiIlBlcnNpc3QgYSBjb2FjaGluZyBzZXNzaW9uIHRvIGRpc2suIiIiCiAgICAgICAgQ09BQ0hfU0VTU0lPTlNfRElSLm1rZGlyKHBhcmVudHM9VHJ1ZSwgZXhpc3Rfb2s9VHJ1ZSkKICAgICAgICBwYXRoID0gQ09BQ0hfU0VTU0lPTlNfRElSIC8gZiJ7c2Vzc2lvbi5zZXNzaW9uSWR9Lmpzb24iCiAgICAgICAgd2l0aCBvcGVuKHBhdGgsICJ3IikgYXMgZjoKICAgICAgICAgICAgZi53cml0ZShzZXNzaW9uLm1vZGVsX2R1bXBfanNvbihpbmRlbnQ9MikpCiAgICAgICAgcmV0dXJuIHBhdGgKCiAgICBkZWYgbG9hZF9zZXNzaW9uKHNlbGYsIHNlc3Npb25faWQ6IHN0cikgLT4gT3B0aW9uYWxbQ29hY2hTZXNzaW9uXToKICAgICAgICAiIiJMb2FkIGEgY29hY2hpbmcgc2Vzc2lvbiBmcm9tIGRpc2suIiIiCiAgICAgICAgcGF0aCA9IENPQUNIX1NFU1NJT05TX0RJUiAvIGYie3Nlc3Npb25faWR9Lmpzb24iCiAgICAgICAgaWYgbm90IHBhdGguZXhpc3RzKCk6CiAgICAgICAgICAgIHJldHVybiBOb25lCiAgICAgICAgdHJ5OgogICAgICAgICAgICB3aXRoIG9wZW4ocGF0aCwgInIiKSBhcyBmOgogICAgICAgICAgICAgICAgZGF0YSA9IGpzb24ubG9hZChmKQogICAgICAgICAgICByZXR1cm4gQ29hY2hTZXNzaW9uKCoqZGF0YSkKICAgICAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgICAgIGxvZ2dlci5lcnJvcigiRmFpbGVkIHRvIGxvYWQgc2Vzc2lvbiAlczogJXMiLCBzZXNzaW9uX2lkLCBlKQogICAgICAgICAgICByZXR1cm4gTm9uZQoKICAgIGRlZiBsaXN0X3Nlc3Npb25zKHNlbGYsIGRlY2tfaWQ6IHN0ciA9IE5vbmUpIC0+IExpc3RbZGljdF06CiAgICAgICAgIiIiTGlzdCBhbGwgY29hY2hpbmcgc2Vzc2lvbnMsIG9wdGlvbmFsbHkgZmlsdGVyZWQgYnkgZGVjay4iIiIKICAgICAgICBpZiBub3QgQ09BQ0hfU0VTU0lPTlNfRElSLmV4aXN0cygpOgogICAgICAgICAgICByZXR1cm4gW10KICAgICAgICBzZXNzaW9ucyA9IFtdCiAgICAgICAgZm9yIGYgaW4gc29ydGVkKENPQUNIX1NFU1NJT05TX0RJUi5nbG9iKCIqLmpzb24iKSwgcmV2ZXJzZT1UcnVlKToKICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgd2l0aCBvcGVuKGYsICJyIikgYXMgZmg6CiAgICAgICAgICAgICAgICAgICAgZGF0YSA9IGpzb24ubG9hZChmaCkKICAgICAgICAgICAgICAgIGlmIGRlY2tfaWQgYW5kIGRhdGEuZ2V0KCJkZWNrSWQiLCAiIikgIT0gZGVja19pZDoKICAgICAgICAgICAgICAgICAgICBjb250aW51ZQogICAgICAgICAgICAgICAgc2Vzc2lvbnMuYXBwZW5kKHsKICAgICAgICAgICAgICAgICAgICAic2Vzc2lvbklkIjogZGF0YS5nZXQoInNlc3Npb25JZCIsIGYuc3RlbSksCiAgICAgICAgICAgICAgICAgICAgImRlY2tJZCI6IGRhdGEuZ2V0KCJkZWNrSWQiLCAiIiksCiAgICAgICAgICAgICAgICAgICAgInRpbWVzdGFtcCI6IGRhdGEuZ2V0KCJ0aW1lc3RhbXAiLCAiIiksCiAgICAgICAgICAgICAgICAgICAgInN1bW1hcnkiOiBkYXRhLmdldCgic3VtbWFyeSIsICIiKVs6MTAwXSwKICAgICAgICAgICAgICAgICAgICAiY3V0c0NvdW50IjogbGVuKGRhdGEuZ2V0KCJz' +
'dWdnZXN0ZWRDdXRzIiwgW10pKSwKICAgICAgICAgICAgICAgICAgICAiYWRkc0NvdW50IjogbGVuKGRhdGEuZ2V0KCJzdWdnZXN0ZWRBZGRzIiwgW10pKSwKICAgICAgICAgICAgICAgIH0pCiAgICAgICAgICAgIGV4Y2VwdCBFeGNlcHRpb246CiAgICAgICAgICAgICAgICBjb250aW51ZQogICAgICAgIHJldHVybiBzZXNzaW9ucwoKICAgICMg4pSA4pSAIE1haW4gQ29hY2hpbmcgUGlwZWxpbmUg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgYXN5bmMgZGVmIHJ1bl9jb2FjaGluZ19zZXNzaW9uKHNlbGYsIGRlY2tfaWQ6IHN0ciwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgZ29hbHM6IE9wdGlvbmFsW0NvYWNoR29hbHNdID0gTm9uZSwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgZmFsbGJhY2tfcmVwb3J0OiBPcHRpb25hbFtEZWNrUmVwb3J0XSA9IE5vbmUsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICkgLT4gQ29hY2hTZXNzaW9uOgogICAgICAgICIiIgogICAgICAgIEZ1bGwgY29hY2hpbmcgcGlwZWxpbmU6CiAgICAgICAgMS4gTG9hZCBkZWNrIHJlcG9ydCAob3IgdXNlIGZhbGxiYWNrIGZyb20gREIgY2FyZCBsaXN0KQogICAgICAgIDIuIElkZW50aWZ5IHVuZGVycGVyZm9ybWVycwogICAgICAgIDMuIEZpbmQgcmVwbGFjZW1lbnQgY2FuZGlkYXRlcyB2aWEgZW1iZWRkaW5ncwogICAgICAgIDQuIEJ1aWxkIHByb21wdAogICAgICAgIDUuIENhbGwgTExNCiAgICAgICAgNi4gUGFyc2UgcmVzcG9uc2UKICAgICAgICA3LiBQZXJzaXN0IHNlc3Npb24KICAgICAgICAiIiIKICAgICAgICAjIDAuIEF1dG8tbG9hZCBlbWJlZGRpbmdzIGlmIG5vdCBsb2FkZWQKICAgICAgICBpZiBub3Qgc2VsZi5lbWJlZGRpbmdzLmxvYWRlZDoKICAgICAgICAgICAgbG9nZ2VyLmluZm8oIkVtYmVkZGluZ3Mgbm90IGxvYWRlZCDigJQgYXR0ZW1wdGluZyBhdXRvLWxvYWQuLi4iKQogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICBsb2FkZWQgPSBzZWxmLmVtYmVkZGluZ3MubG9hZCgpCiAgICAgICAgICAgICAgICBpZiBsb2FkZWQ6CiAgICAgICAgICAgICAgICAgICAgbG9nZ2VyLmluZm8oIkVtYmVkZGluZ3MgYXV0by1sb2FkZWQ6ICVkIGNhcmRzIiwgc2VsZi5lbWJlZGRpbmdzLmNhcmRfY291bnQpCiAgICAgICAgICAgICAgICBlbHNlOgogICAgICAgICAgICAgICAgICAgIGxvZ2dlci53YXJuaW5nKCJFbWJlZGRpbmdzIGF1dG8tbG9hZCByZXR1cm5lZCBGYWxzZSIpCiAgICAgICAgICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICAgICAgICAgIGxvZ2dlci53YXJuaW5nKCJFbWJlZGRpbmdzIGF1dG8tbG9hZCBmYWlsZWQ6ICVzIiwgZSkKCiAgICAgICAgIyAxLiBMb2FkIGRlY2sgcmVwb3J0IOKAlCB0cnkgc2ltdWxhdGlvbiByZXBvcnQgZmlyc3QsIHRoZW4gZmFsbGJhY2sKICAgICAgICByZXBvcnQgPSBzZWxmLmxvYWRfZGVja19yZXBvcnQoZGVja19pZCkKICAgICAgICBpZiByZXBvcnQgaXMgTm9uZSBhbmQgZmFsbGJhY2tfcmVwb3J0IGlzIG5vdCBOb25lOgogICAgICAgICAgICByZXBvcnQgPSBmYWxsYmFja19yZXBvcnQKICAgICAgICAgICAgbG9nZ2VyLmluZm8oIlVzaW5nIERCLWJ1aWx0IGZhbGxiYWNrIHJlcG9ydCBmb3IgJyVzJyAobm8gc2ltdWxhdGlvbiBkYXRhKSIsIGRlY2tfaWQpCiAgICAgICAgaWYgcmVwb3J0IGlzIE5vbmU6CiAgICAgICAgICAgIHJhaXNlIFZhbHVlRXJyb3IoZiJEZWNrIHJlcG9ydCBub3QgZm91bmQgZm9yOiB7ZGVja19pZH0iKQoKICAgICAgICAjIDIuIEdldCBkZWNrIGNhcmQgbmFtZXMgKGZvciBleGNsdXNpb24gZnJvbSBjYW5kaWRhdGVzKQogICAgICAgIGRlY2tfY2FyZF9uYW1lcyA9IFtjLm5hbWUgZm9yIGMgaW4gcmVwb3J0LmNhcmRzXQoKICAgICAgICAjIDMuIEZpbmQgcmVwbGFjZW1lbnQgY2FuZGlkYXRlcyBmb3IgdW5kZXJwZXJmb3JtZXJzCiAgICAgICAgY2FuZGlkYXRlczogRGljdFtzdHIsIExpc3RbZGljdF1dID0ge30KCiAgICAgICAgdW5kZXJwZXJmb3JtZXJzID0gcmVwb3J0LnVuZGVycGVyZm9ybWVyc1s6TUFYX1VOREVSUEVSRk9STUVSU10KICAgICAgICBpZiBub3QgdW5kZXJwZXJmb3JtZXJzOgogICAgICAgICAgICAjIElmIG5vIGV4cGxpY2l0IHVuZGVycGVyZm9ybWVycywgcGlj' +
'ayBsb3dlc3QgaW1wYWN0IGNhcmRzCiAgICAgICAgICAgIHNvcnRlZF9jYXJkcyA9IHNvcnRlZChyZXBvcnQuY2FyZHMsIGtleT1sYW1iZGEgYzogYy5pbXBhY3RTY29yZSkKICAgICAgICAgICAgdW5kZXJwZXJmb3JtZXJzID0gWwogICAgICAgICAgICAgICAgYy5uYW1lIGZvciBjIGluIHNvcnRlZF9jYXJkc1s6TUFYX1VOREVSUEVSRk9STUVSU10KICAgICAgICAgICAgICAgIGlmIGMuaW1wYWN0U2NvcmUgPCBVTkRFUlBFUkZPUk1FUl9JTVBBQ1RfVEhSRVNIT0xECiAgICAgICAgICAgIF0KCiAgICAgICAgIyBJZiBzdGlsbCBubyB1bmRlcnBlcmZvcm1lcnMgYW5kIHdlIGhhdmUgY2FyZHMsIHBpY2sgYm90dG9tIE4gYnkgaW1wYWN0CiAgICAgICAgaWYgbm90IHVuZGVycGVyZm9ybWVycyBhbmQgcmVwb3J0LmNhcmRzOgogICAgICAgICAgICBzb3J0ZWRfY2FyZHMgPSBzb3J0ZWQocmVwb3J0LmNhcmRzLCBrZXk9bGFtYmRhIGM6IGMuaW1wYWN0U2NvcmUpCiAgICAgICAgICAgICMgVGFrZSB0aGUgYm90dG9tIDUgbm9uLWxhbmQgY2FyZHMgYXMgY2FuZGlkYXRlcyBmb3IgY3V0cwogICAgICAgICAgICB1bmRlcnBlcmZvcm1lcnMgPSBbCiAgICAgICAgICAgICAgICBjLm5hbWUgZm9yIGMgaW4gc29ydGVkX2NhcmRzWzo1XQogICAgICAgICAgICAgICAgaWYgbm90IGFueSh0IGluIChjLnRhZ3Mgb3IgW10pIGZvciB0IGluIFtdKQogICAgICAgICAgICBdWzpNQVhfVU5ERVJQRVJGT1JNRVJTXQoKICAgICAgICBpZiBzZWxmLmVtYmVkZGluZ3MubG9hZGVkOgogICAgICAgICAgICBmb3IgY2FyZF9uYW1lIGluIHVuZGVycGVyZm9ybWVyczoKICAgICAgICAgICAgICAgIG1hdGNoZXMgPSBzZWxmLmVtYmVkZGluZ3MuZmluZF9yZXBsYWNlbWVudHMoCiAgICAgICAgICAgICAgICAgICAgdW5kZXJwZXJmb3JtZXI9Y2FyZF9uYW1lLAogICAgICAgICAgICAgICAgICAgIGRlY2tfY29sb3JzPXJlcG9ydC5jb2xvcklkZW50aXR5LAogICAgICAgICAgICAgICAgICAgIGRlY2tfY2FyZF9uYW1lcz1kZWNrX2NhcmRfbmFtZXMsCiAgICAgICAgICAgICAgICAgICAgdG9wX249TUFYX0NBTkRJREFURVNfUEVSX1VOREVSUEVSRk9STUVSLAogICAgICAgICAgICAgICAgKQogICAgICAgICAgICAgICAgaWYgbWF0Y2hlczoKICAgICAgICAgICAgICAgICAgICBjYW5kaWRhdGVzW2NhcmRfbmFtZV0gPSBbbS50b19kaWN0KCkgZm9yIG0gaW4gbWF0Y2hlc10KICAgICAgICBlbHNlOgogICAgICAgICAgICBsb2dnZXIud2FybmluZygiRW1iZWRkaW5ncyBub3QgbG9hZGVkIOKAlCBza2lwcGluZyBjYW5kaWRhdGUgc2VhcmNoIikKCiAgICAgICAgIyA0LiBCdWlsZCBwcm9tcHRzCiAgICAgICAgc3lzdGVtX3Byb21wdCA9IGJ1aWxkX3N5c3RlbV9wcm9tcHQocmVwb3J0LCBnb2FscykKICAgICAgICB1c2VyX3Byb21wdCA9IGJ1aWxkX3VzZXJfcHJvbXB0KHJlcG9ydCwgY2FuZGlkYXRlcykKCiAgICAgICAgIyA1LiBDYWxsIExMTQogICAgICAgIGxvZ2dlci5pbmZvKCJDYWxsaW5nIExMTSBmb3IgZGVjazogJXMiLCBkZWNrX2lkKQogICAgICAgIGxsbV9yZXNwb25zZSA9IGF3YWl0IHNlbGYubGxtLmNoYXQoc3lzdGVtX3Byb21wdCwgdXNlcl9wcm9tcHQpCgogICAgICAgICMgNi4gUGFyc2UgcmVzcG9uc2UgaW50byBDb2FjaFNlc3Npb24KICAgICAgICBzZXNzaW9uX2lkID0gZiJzZXNzLXtkYXRldGltZS5ub3codGltZXpvbmUudXRjKS5zdHJmdGltZSgnJVklbSVkLSVIJU0lUycpfS17dXVpZC51dWlkNCgpLmhleFs6Nl19IgoKICAgICAgICBzZXNzaW9uID0gQ29hY2hTZXNzaW9uKAogICAgICAgICAgICBzZXNzaW9uSWQ9c2Vzc2lvbl9pZCwKICAgICAgICAgICAgZGVja0lkPWRlY2tfaWQsCiAgICAgICAgICAgIHRpbWVzdGFtcD1kYXRldGltZS5ub3codGltZXpvbmUudXRjKS5pc29mb3JtYXQoKSwKICAgICAgICAgICAgbW9kZWxVc2VkPWxsbV9yZXNwb25zZS5tb2RlbCwKICAgICAgICAgICAgcHJvbXB0VG9rZW5zPWxsbV9yZXNwb25zZS5wcm9tcHRfdG9rZW5zLAogICAgICAgICAgICBjb21wbGV0aW9uVG9rZW5zPWxsbV9yZXNwb25zZS5jb21wbGV0aW9uX3Rva2VucywKICAgICAgICAgICAgZ29hbHM9Z29hbHMsCiAgICAgICAgKQoKICAgICAgICBpZiBsbG1fcmVzcG9uc2UucGFyc2VkX2pzb246CiAgICAgICAgICAgIHNlbGYuX3BvcHVsYXRlX3Nlc3Npb25fZnJvbV9qc29uKHNlc3Npb24sIGxsbV9yZXNwb25zZS5wYXJzZWRfanNvbikKICAgICAgICBlbHNlOgogICAgICAgICAg' +
'ICAjIEZhbGxiYWNrOiBzdG9yZSByYXcgdGV4dAogICAgICAgICAgICBzZXNzaW9uLnJhd1RleHRFeHBsYW5hdGlvbiA9IGxsbV9yZXNwb25zZS5jb250ZW50CiAgICAgICAgICAgIHNlc3Npb24uc3VtbWFyeSA9ICJMTE0gcmV0dXJuZWQgbm9uLUpTT04gcmVzcG9uc2UuIFNlZSByYXcgZXhwbGFuYXRpb24uIgoKICAgICAgICAjIDcuIFBlcnNpc3QKICAgICAgICBzZWxmLnNhdmVfc2Vzc2lvbihzZXNzaW9uKQogICAgICAgIGxvZ2dlci5pbmZvKCJDb2FjaGluZyBzZXNzaW9uIHNhdmVkOiAlcyIsIHNlc3Npb25faWQpCgogICAgICAgIHJldHVybiBzZXNzaW9uCgogICAgZGVmIF9wb3B1bGF0ZV9zZXNzaW9uX2Zyb21fanNvbihzZWxmLCBzZXNzaW9uOiBDb2FjaFNlc3Npb24sIGRhdGE6IGRpY3QpOgogICAgICAgICIiIlBhcnNlIExMTSBKU09OIHJlc3BvbnNlIGludG8gQ29hY2hTZXNzaW9uIGZpZWxkcy4iIiIKICAgICAgICBzZXNzaW9uLnN1bW1hcnkgPSBkYXRhLmdldCgic3VtbWFyeSIsICIiKQogICAgICAgIHNlc3Npb24ucmF3VGV4dEV4cGxhbmF0aW9uID0gZGF0YS5nZXQoInJhd1RleHRFeHBsYW5hdGlvbiIsICIiKQogICAgICAgIHNlc3Npb24ubWFuYUJhc2VBZHZpY2UgPSBkYXRhLmdldCgibWFuYUJhc2VBZHZpY2UiKQoKICAgICAgICAjIEhldXJpc3RpYyBoaW50cwogICAgICAgIGhpbnRzID0gZGF0YS5nZXQoImhldXJpc3RpY0hpbnRzIiwgW10pCiAgICAgICAgc2Vzc2lvbi5oZXVyaXN0aWNIaW50cyA9IGhpbnRzIGlmIGlzaW5zdGFuY2UoaGludHMsIGxpc3QpIGVsc2UgW3N0cihoaW50cyldCgogICAgICAgICMgU3VnZ2VzdGVkIGN1dHMKICAgICAgICBmb3IgY3V0X2RhdGEgaW4gZGF0YS5nZXQoInN1Z2dlc3RlZEN1dHMiLCBbXSk6CiAgICAgICAgICAgIGlmIGlzaW5zdGFuY2UoY3V0X2RhdGEsIGRpY3QpOgogICAgICAgICAgICAgICAgc2Vzc2lvbi5zdWdnZXN0ZWRDdXRzLmFwcGVuZChTdWdnZXN0ZWRDdXQoCiAgICAgICAgICAgICAgICAgICAgY2FyZE5hbWU9Y3V0X2RhdGEuZ2V0KCJjYXJkTmFtZSIsICIiKSwKICAgICAgICAgICAgICAgICAgICByZWFzb249Y3V0X2RhdGEuZ2V0KCJyZWFzb24iLCAiIiksCiAgICAgICAgICAgICAgICAgICAgcmVwbGFjZW1lbnRPcHRpb25zPWN1dF9kYXRhLmdldCgicmVwbGFjZW1lbnRPcHRpb25zIiwgW10pLAogICAgICAgICAgICAgICAgKSkKCiAgICAgICAgIyBTdWdnZXN0ZWQgYWRkcwogICAgICAgIGZvciBhZGRfZGF0YSBpbiBkYXRhLmdldCgic3VnZ2VzdGVkQWRkcyIsIFtdKToKICAgICAgICAgICAgaWYgaXNpbnN0YW5jZShhZGRfZGF0YSwgZGljdCk6CiAgICAgICAgICAgICAgICBzZXNzaW9uLnN1Z2dlc3RlZEFkZHMuYXBwZW5kKFN1Z2dlc3RlZEFkZCgKICAgICAgICAgICAgICAgICAgICBjYXJkTmFtZT1hZGRfZGF0YS5nZXQoImNhcmROYW1lIiwgIiIpLAogICAgICAgICAgICAgICAgICAgIHJvbGU9YWRkX2RhdGEuZ2V0KCJyb2xlIiwgIiIpLAogICAgICAgICAgICAgICAgICAgIHJlYXNvbj1hZGRfZGF0YS5nZXQoInJlYXNvbiIsICIiKSwKICAgICAgICAgICAgICAgICAgICBzeW5lcmd5V2l0aD1hZGRfZGF0YS5nZXQoInN5bmVyZ3lXaXRoIiwgW10pLAogICAgICAgICAgICAgICAgKSkKCiAgICAjIOKUgOKUgCBTdGF0dXMgQ2hlY2sg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgZGVmIGdldF9zdGF0dXMoc2VsZikgLT4gQ29hY2hTdGF0dXM6CiAgICAgICAgIiIiQ2hlY2sgdGhlIGhlYWx0aCBvZiBhbGwgY29hY2ggc3Vic3lzdGVtcy4iIiIKICAgICAgICBzdGF0dXMgPSBDb2FjaFN0YXR1cygpCgogICAgICAgICMgQ2hlY2sgTExNCiAgICAgICAgbGxtX3N0YXR1cyA9IHNlbGYubGxtLmNoZWNrX2Nvbm5lY3Rpb24oKQogICAgICAgIHN0YXR1cy5sbG1Db25uZWN0ZWQgPSBsbG1fc3RhdHVzLmdldCgiY29ubmVjdGVkIiwgRmFsc2UpCiAgICAgICAgc3RhdHVzLmxsbU1vZGVsID0gbGxtX3N0YXR1cy5nZXQoImFjdGl2ZV9tb2RlbCIpCiAgICAgICAgc3RhdHVzLmxsbU1vZGVscyA9IGxsbV9zdGF0dXMuZ2V0KCJtb2RlbHMiLCBbXSkKICAgICAgICBpZiBub3Qgc3RhdHVzLmxsbUNvbm5lY3RlZDoKICAgICAgICAgICAgc3RhdHVzLmVycm9yID0gbGxtX3N0YXR1cy5nZXQoImVycm9yIiwg' +
'IkxNIFN0dWRpbyBub3QgcmVhY2hhYmxlIikKCiAgICAgICAgIyBDaGVjayBlbWJlZGRpbmdzCiAgICAgICAgc3RhdHVzLmVtYmVkZGluZ3NMb2FkZWQgPSBzZWxmLmVtYmVkZGluZ3MubG9hZGVkCiAgICAgICAgc3RhdHVzLmVtYmVkZGluZ0NhcmRzID0gc2VsZi5lbWJlZGRpbmdzLmNhcmRfY291bnQKCiAgICAgICAgIyBDaGVjayBkZWNrIHJlcG9ydHMKICAgICAgICBzdGF0dXMuZGVja1JlcG9ydHNBdmFpbGFibGUgPSBsZW4oc2VsZi5saXN0X2RlY2tfcmVwb3J0cygpKQoKICAgICAgICByZXR1cm4gc3RhdHVzCg=='
'dWdnZXN0ZWRDdXRzIiwgW10pKSwKICAgICAgICAgICAgICAgICAgICAiYWRkc0NvdW50IjogbGVuKGRhdGEuZ2V0KCJzdWdnZXN0ZWRBZGRzIiwgW10pKSwKICAgICAgICAgICAgICAgIH0pCiAgICAgICAgICAgIGV4Y2VwdCBFeGNlcHRpb246CiAgICAgICAgICAgICAgICBjb250aW51ZQogICAgICAgIHJldHVybiBzZXNzaW9ucwoKICAgICMg4pSA4pSAIE1haW4gQ29hY2hpbmcgUGlwZWxpbmUg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgYXN5bmMgZGVmIHJ1bl9jb2FjaGluZ19zZXNzaW9uKHNlbGYsIGRlY2tfaWQ6IHN0ciwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgZ29hbHM6IE9wdGlvbmFsW0NvYWNoR29hbHNdID0gTm9uZQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICApIC0+IENvYWNoU2Vzc2lvbjoKICAgICAgICAiIiIKICAgICAgICBGdWxsIGNvYWNoaW5nIHBpcGVsaW5lOgogICAgICAgIDEuIExvYWQgZGVjayByZXBvcnQKICAgICAgICAyLiBJZGVudGlmeSB1bmRlcnBlcmZvcm1lcnMKICAgICAgICAzLiBGaW5kIHJlcGxhY2VtZW50IGNhbmRpZGF0ZXMgdmlhIGVtYmVkZGluZ3MKICAgICAgICA0LiBCdWlsZCBwcm9tcHQKICAgICAgICA1LiBDYWxsIExMTQogICAgICAgIDYuIFBhcnNlIHJlc3BvbnNlCiAgICAgICAgNy4gUGVyc2lzdCBzZXNzaW9uCiAgICAgICAgIiIiCiAgICAgICAgIyAwLiBBdXRvLWxvYWQgZW1iZWRkaW5ncyBpZiBub3QgbG9hZGVkCiAgICAgICAgaWYgbm90IHNlbGYuZW1iZWRkaW5ncy5sb2FkZWQ6CiAgICAgICAgICAgIGxvZ2dlci5pbmZvKCJFbWJlZGRpbmdzIG5vdCBsb2FkZWQg4oCUIGF0dGVtcHRpbmcgYXV0by1sb2FkLi4uIikKICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgbG9hZGVkID0gc2VsZi5lbWJlZGRpbmdzLmxvYWQoKQogICAgICAgICAgICAgICAgaWYgbG9hZGVkOgogICAgICAgICAgICAgICAgICAgIGxvZ2dlci5pbmZvKCJFbWJlZGRpbmdzIGF1dG8tbG9hZGVkOiAlZCBjYXJkcyIsIHNlbGYuZW1iZWRkaW5ncy5jYXJkX2NvdW50KQogICAgICAgICAgICAgICAgZWxzZToKICAgICAgICAgICAgICAgICAgICBsb2dnZXIud2FybmluZygiRW1iZWRkaW5ncyBhdXRvLWxvYWQgcmV0dXJuZWQgRmFsc2UiKQogICAgICAgICAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgICAgICAgICBsb2dnZXIud2FybmluZygiRW1iZWRkaW5ncyBhdXRvLWxvYWQgZmFpbGVkOiAlcyIsIGUpCgogICAgICAgICMgMS4gTG9hZCBkZWNrIHJlcG9ydAogICAgICAgIHJlcG9ydCA9IHNlbGYubG9hZF9kZWNrX3JlcG9ydChkZWNrX2lkKQogICAgICAgIGlmIHJlcG9ydCBpcyBOb25lOgogICAgICAgICAgICByYWlzZSBWYWx1ZUVycm9yKGYiRGVjayByZXBvcnQgbm90IGZvdW5kIGZvcjoge2RlY2tfaWR9IikKCiAgICAgICAgIyAyLiBHZXQgZGVjayBjYXJkIG5hbWVzIChmb3IgZXhjbHVzaW9uIGZyb20gY2FuZGlkYXRlcykKICAgICAgICBkZWNrX2NhcmRfbmFtZXMgPSBbYy5uYW1lIGZvciBjIGluIHJlcG9ydC5jYXJkc10KCiAgICAgICAgIyAzLiBGaW5kIHJlcGxhY2VtZW50IGNhbmRpZGF0ZXMgZm9yIHVuZGVycGVyZm9ybWVycwogICAgICAgIGNhbmRpZGF0ZXM6IERpY3Rbc3RyLCBMaXN0W2RpY3RdXSA9IHt9CgogICAgICAgIHVuZGVycGVyZm9ybWVycyA9IHJlcG9ydC51bmRlcnBlcmZvcm1lcnNbOk1BWF9VTkRFUlBFUkZPUk1FUlNdCiAgICAgICAgaWYgbm90IHVuZGVycGVyZm9ybWVyczoKICAgICAgICAgICAgIyBJZiBubyBleHBsaWNpdCB1bmRlcnBlcmZvcm1lcnMsIHBpY2sgbG93ZXN0IGltcGFjdCBjYXJkcwogICAgICAgICAgICBzb3J0ZWRfY2FyZHMgPSBzb3J0ZWQocmVwb3J0LmNhcmRzLCBrZXk9bGFtYmRhIGM6IGMuaW1wYWN0U2NvcmUpCiAgICAgICAgICAgIHVuZGVycGVyZm9ybWVycyA9IFsKICAgICAgICAgICAgICAgIGMubmFtZSBmb3IgYyBpbiBzb3J0ZWRfY2FyZHNbOk1BWF9VTkRFUlBFUkZPUk1FUlNdCiAgICAgICAgICAgICAgICBpZiBjLmltcGFjdFNjb3JlIDwgVU5ERVJQRVJGT1JNRVJfSU1QQUNUX1RIUkVTSE9MRAogICAgICAgICAgICBdCgogICAgICAgICMgSWYgc3RpbGwgbm8gdW5kZXJwZXJmb3JtZXJzIGFuZCB3ZSBoYXZlIGNhcmRzLCBwaWNrIGJvdHRvbSBOIGJ5IGltcGFj' +
'dAogICAgICAgIGlmIG5vdCB1bmRlcnBlcmZvcm1lcnMgYW5kIHJlcG9ydC5jYXJkczoKICAgICAgICAgICAgc29ydGVkX2NhcmRzID0gc29ydGVkKHJlcG9ydC5jYXJkcywga2V5PWxhbWJkYSBjOiBjLmltcGFjdFNjb3JlKQogICAgICAgICAgICAjIFRha2UgdGhlIGJvdHRvbSA1IG5vbi1sYW5kIGNhcmRzIGFzIGNhbmRpZGF0ZXMgZm9yIGN1dHMKICAgICAgICAgICAgdW5kZXJwZXJmb3JtZXJzID0gWwogICAgICAgICAgICAgICAgYy5uYW1lIGZvciBjIGluIHNvcnRlZF9jYXJkc1s6NV0KICAgICAgICAgICAgICAgIGlmIG5vdCBhbnkodCBpbiAoYy50YWdzIG9yIFtdKSBmb3IgdCBpbiBbXSkKICAgICAgICAgICAgXVs6TUFYX1VOREVSUEVSRk9STUVSU10KCiAgICAgICAgaWYgc2VsZi5lbWJlZGRpbmdzLmxvYWRlZDoKICAgICAgICAgICAgZm9yIGNhcmRfbmFtZSBpbiB1bmRlcnBlcmZvcm1lcnM6CiAgICAgICAgICAgICAgICBtYXRjaGVzID0gc2VsZi5lbWJlZGRpbmdzLmZpbmRfcmVwbGFjZW1lbnRzKAogICAgICAgICAgICAgICAgICAgIHVuZGVycGVyZm9ybWVyPWNhcmRfbmFtZSwKICAgICAgICAgICAgICAgICAgICBkZWNrX2NvbG9ycz1yZXBvcnQuY29sb3JJZGVudGl0eSwKICAgICAgICAgICAgICAgICAgICBkZWNrX2NhcmRfbmFtZXM9ZGVja19jYXJkX25hbWVzLAogICAgICAgICAgICAgICAgICAgIHRvcF9uPU1BWF9DQU5ESURBVEVTX1BFUl9VTkRFUlBFUkZPUk1FUiwKICAgICAgICAgICAgICAgICkKICAgICAgICAgICAgICAgIGlmIG1hdGNoZXM6CiAgICAgICAgICAgICAgICAgICAgY2FuZGlkYXRlc1tjYXJkX25hbWVdID0gW20udG9fZGljdCgpIGZvciBtIGluIG1hdGNoZXNdCiAgICAgICAgZWxzZToKICAgICAgICAgICAgbG9nZ2VyLndhcm5pbmcoIkVtYmVkZGluZ3Mgbm90IGxvYWRlZCDigJQgc2tpcHBpbmcgY2FuZGlkYXRlIHNlYXJjaCIpCgogICAgICAgICMgNC4gQnVpbGQgcHJvbXB0cwogICAgICAgIHN5c3RlbV9wcm9tcHQgPSBidWlsZF9zeXN0ZW1fcHJvbXB0KHJlcG9ydCwgZ29hbHMpCiAgICAgICAgdXNlcl9wcm9tcHQgPSBidWlsZF91c2VyX3Byb21wdChyZXBvcnQsIGNhbmRpZGF0ZXMpCgogICAgICAgICMgNS4gQ2FsbCBMTE0KICAgICAgICBsb2dnZXIuaW5mbygiQ2FsbGluZyBMTE0gZm9yIGRlY2s6ICVzIiwgZGVja19pZCkKICAgICAgICBsbG1fcmVzcG9uc2UgPSBhd2FpdCBzZWxmLmxsbS5jaGF0KHN5c3RlbV9wcm9tcHQsIHVzZXJfcHJvbXB0KQoKICAgICAgICAjIDYuIFBhcnNlIHJlc3BvbnNlIGludG8gQ29hY2hTZXNzaW9uCiAgICAgICAgc2Vzc2lvbl9pZCA9IGYic2Vzcy17ZGF0ZXRpbWUubm93KHRpbWV6b25lLnV0Yykuc3RyZnRpbWUoJyVZJW0lZC0lSCVNJVMnKX0te3V1aWQudXVpZDQoKS5oZXhbOjZdfSIKCiAgICAgICAgc2Vzc2lvbiA9IENvYWNoU2Vzc2lvbigKICAgICAgICAgICAgc2Vzc2lvbklkPXNlc3Npb25faWQsCiAgICAgICAgICAgIGRlY2tJZD1kZWNrX2lkLAogICAgICAgICAgICB0aW1lc3RhbXA9ZGF0ZXRpbWUubm93KHRpbWV6b25lLnV0YykuaXNvZm9ybWF0KCksCiAgICAgICAgICAgIG1vZGVsVXNlZD1sbG1fcmVzcG9uc2UubW9kZWwsCiAgICAgICAgICAgIHByb21wdFRva2Vucz1sbG1fcmVzcG9uc2UucHJvbXB0X3Rva2VucywKICAgICAgICAgICAgY29tcGxldGlvblRva2Vucz1sbG1fcmVzcG9uc2UuY29tcGxldGlvbl90b2tlbnMsCiAgICAgICAgICAgIGdvYWxzPWdvYWxzLAogICAgICAgICkKCiAgICAgICAgaWYgbGxtX3Jlc3BvbnNlLnBhcnNlZF9qc29uOgogICAgICAgICAgICBzZWxmLl9wb3B1bGF0ZV9zZXNzaW9uX2Zyb21fanNvbihzZXNzaW9uLCBsbG1fcmVzcG9uc2UucGFyc2VkX2pzb24pCiAgICAgICAgZWxzZToKICAgICAgICAgICAgIyBGYWxsYmFjazogc3RvcmUgcmF3IHRleHQKICAgICAgICAgICAgc2Vzc2lvbi5yYXdUZXh0RXhwbGFuYXRpb24gPSBsbG1fcmVzcG9uc2UuY29udGVudAogICAgICAgICAgICBzZXNzaW9uLnN1bW1hcnkgPSAiTExNIHJldHVybmVkIG5vbi1KU09OIHJlc3BvbnNlLiBTZWUgcmF3IGV4cGxhbmF0aW9uLiIKCiAgICAgICAgIyA3LiBQZXJzaXN0CiAgICAgICAgc2VsZi5zYXZlX3Nlc3Npb24oc2Vzc2lvbikKICAgICAgICBsb2dnZXIuaW5mbygiQ29hY2hpbmcgc2Vzc2lvbiBzYXZlZDogJXMiLCBzZXNzaW9uX2lkKQoKICAgICAgICByZXR1cm4gc2Vzc2lvbgoKICAgIGRlZiBfcG9wdWxhdGVfc2Vzc2lvbl9mcm9tX2pzb24oc2Vs' +
'Ziwgc2Vzc2lvbjogQ29hY2hTZXNzaW9uLCBkYXRhOiBkaWN0KToKICAgICAgICAiIiJQYXJzZSBMTE0gSlNPTiByZXNwb25zZSBpbnRvIENvYWNoU2Vzc2lvbiBmaWVsZHMuIiIiCiAgICAgICAgc2Vzc2lvbi5zdW1tYXJ5ID0gZGF0YS5nZXQoInN1bW1hcnkiLCAiIikKICAgICAgICBzZXNzaW9uLnJhd1RleHRFeHBsYW5hdGlvbiA9IGRhdGEuZ2V0KCJyYXdUZXh0RXhwbGFuYXRpb24iLCAiIikKICAgICAgICBzZXNzaW9uLm1hbmFCYXNlQWR2aWNlID0gZGF0YS5nZXQoIm1hbmFCYXNlQWR2aWNlIikKCiAgICAgICAgIyBIZXVyaXN0aWMgaGludHMKICAgICAgICBoaW50cyA9IGRhdGEuZ2V0KCJoZXVyaXN0aWNIaW50cyIsIFtdKQogICAgICAgIHNlc3Npb24uaGV1cmlzdGljSGludHMgPSBoaW50cyBpZiBpc2luc3RhbmNlKGhpbnRzLCBsaXN0KSBlbHNlIFtzdHIoaGludHMpXQoKICAgICAgICAjIFN1Z2dlc3RlZCBjdXRzCiAgICAgICAgZm9yIGN1dF9kYXRhIGluIGRhdGEuZ2V0KCJzdWdnZXN0ZWRDdXRzIiwgW10pOgogICAgICAgICAgICBpZiBpc2luc3RhbmNlKGN1dF9kYXRhLCBkaWN0KToKICAgICAgICAgICAgICAgIHNlc3Npb24uc3VnZ2VzdGVkQ3V0cy5hcHBlbmQoU3VnZ2VzdGVkQ3V0KAogICAgICAgICAgICAgICAgICAgIGNhcmROYW1lPWN1dF9kYXRhLmdldCgiY2FyZE5hbWUiLCAiIiksCiAgICAgICAgICAgICAgICAgICAgcmVhc29uPWN1dF9kYXRhLmdldCgicmVhc29uIiwgIiIpLAogICAgICAgICAgICAgICAgICAgIHJlcGxhY2VtZW50T3B0aW9ucz1jdXRfZGF0YS5nZXQoInJlcGxhY2VtZW50T3B0aW9ucyIsIFtdKSwKICAgICAgICAgICAgICAgICkpCgogICAgICAgICMgU3VnZ2VzdGVkIGFkZHMKICAgICAgICBmb3IgYWRkX2RhdGEgaW4gZGF0YS5nZXQoInN1Z2dlc3RlZEFkZHMiLCBbXSk6CiAgICAgICAgICAgIGlmIGlzaW5zdGFuY2UoYWRkX2RhdGEsIGRpY3QpOgogICAgICAgICAgICAgICAgc2Vzc2lvbi5zdWdnZXN0ZWRBZGRzLmFwcGVuZChTdWdnZXN0ZWRBZGQoCiAgICAgICAgICAgICAgICAgICAgY2FyZE5hbWU9YWRkX2RhdGEuZ2V0KCJjYXJkTmFtZSIsICIiKSwKICAgICAgICAgICAgICAgICAgICByb2xlPWFkZF9kYXRhLmdldCgicm9sZSIsICIiKSwKICAgICAgICAgICAgICAgICAgICByZWFzb249YWRkX2RhdGEuZ2V0KCJyZWFzb24iLCAiIiksCiAgICAgICAgICAgICAgICAgICAgc3luZXJneVdpdGg9YWRkX2RhdGEuZ2V0KCJzeW5lcmd5V2l0aCIsIFtdKSwKICAgICAgICAgICAgICAgICkpCgogICAgIyDilIDilIAgU3RhdHVzIENoZWNrIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAoKICAgIGRlZiBnZXRfc3RhdHVzKHNlbGYpIC0+IENvYWNoU3RhdHVzOgogICAgICAgICIiIkNoZWNrIHRoZSBoZWFsdGggb2YgYWxsIGNvYWNoIHN1YnN5c3RlbXMuIiIiCiAgICAgICAgc3RhdHVzID0gQ29hY2hTdGF0dXMoKQoKICAgICAgICAjIENoZWNrIExMTQogICAgICAgIGxsbV9zdGF0dXMgPSBzZWxmLmxsbS5jaGVja19jb25uZWN0aW9uKCkKICAgICAgICBzdGF0dXMubGxtQ29ubmVjdGVkID0gbGxtX3N0YXR1cy5nZXQoImNvbm5lY3RlZCIsIEZhbHNlKQogICAgICAgIHN0YXR1cy5sbG1Nb2RlbCA9IGxsbV9zdGF0dXMuZ2V0KCJhY3RpdmVfbW9kZWwiKQogICAgICAgIHN0YXR1cy5sbG1Nb2RlbHMgPSBsbG1fc3RhdHVzLmdldCgibW9kZWxzIiwgW10pCiAgICAgICAgaWYgbm90IHN0YXR1cy5sbG1Db25uZWN0ZWQ6CiAgICAgICAgICAgIHN0YXR1cy5lcnJvciA9IGxsbV9zdGF0dXMuZ2V0KCJlcnJvciIsICJMTSBTdHVkaW8gbm90IHJlYWNoYWJsZSIpCgogICAgICAgICMgQ2hlY2sgZW1iZWRkaW5ncwogICAgICAgIHN0YXR1cy5lbWJlZGRpbmdzTG9hZGVkID0gc2VsZi5lbWJlZGRpbmdzLmxvYWRlZAogICAgICAgIHN0YXR1cy5lbWJlZGRpbmdDYXJkcyA9IHNlbGYuZW1iZWRkaW5ncy5jYXJkX2NvdW50CgogICAgICAgICMgQ2hlY2sgZGVjayByZXBvcnRzCiAgICAgICAgc3RhdHVzLmRlY2tSZXBvcnRzQXZhaWxhYmxlID0gbGVuKHNlbGYubGlzdF9kZWNrX3JlcG9ydHMoKSkKCiAgICAgICAgcmV0dXJuIHN0YXR1cwo='
Install-B64 -R 'coach\coach_service.py' -B $b
# coach/config.py (3,840 bytes)
# coach/config.py (3,839 bytes)
$b = 'IiIiCkNvbW1hbmRlciBBSSBMYWIg4oCUIENvYWNoIENvbmZpZ3VyYXRpb24K4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCkNlbnRyYWwgY29uZmlnIGZvciBMTSBTdHVkaW8sIGVtYmVkZGluZ3MsIGFuZCBjb2FjaCBzZXJ2aWNlIHBhdGhzLgpBbGwgcGF0aHMgYXJlIHJlbGF0aXZlIHRvIHRoZSBjb21tYW5kZXItYWktbGFiIHJvb3QgZGlyZWN0b3J5LgoiIiIKCmltcG9ydCBvcwpmcm9tIHBhdGhsaWIgaW1wb3J0IFBhdGgKCiMg4pSA4pSAIEJhc2UgUGF0aHMg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACiMgUmVzb2x2ZSByZWxhdGl2ZSB0byB3aGVyZXZlciB0aGUgbGFiIGlzIGluc3RhbGxlZApMQUJfUk9PVCA9IFBhdGgob3MuZW52aXJvbi5nZXQoIkNPTU1BTkRFUl9MQUJfUk9PVCIsIFBhdGgoX19maWxlX18pLnBhcmVudC5wYXJlbnQpKQoKIyDilIDilIAgTE0gU3R1ZGlvIChPcGVuQUktY29tcGF0aWJsZSBBUEkpIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgApMTV9TVFVESU9fVVJMID0gb3MuZW52aXJvbi5nZXQoIkxNX1NUVURJT19VUkwiLCAiaHR0cDovLzE5Mi4xNjguMC4xMjI6MTIzNC92MSIpCkxNX1NUVURJT19NT0RFTCA9IG9zLmVudmlyb24uZ2V0KCJMTV9TVFVESU9fTU9ERUwiLCAibG9jYWwtbW9kZWwiKQpMTV9TVFVESU9fVElNRU9VVCA9IGludChvcy5lbnZpcm9uLmdldCgiTE1fU1RVRElPX1RJTUVPVVQiLCAiMTIwIikpICAjIHNlY29uZHMKTE1fU1RVRElPX01BWF9SRVRSSUVTID0gMwoKIyDilIDilIAgRW1iZWRkaW5ncyDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKRU1CRURESU5HU19ESVIgPSBMQUJfUk9PVCAvICJkYXRhIgpFTUJFRERJTkdTX05QWiA9IEVNQkVERElOR1NfRElSIC8gIm10Zy1lbWJlZGRpbmdzLm5weiIKRU1CRURESU5HU19QQVJRVUVUID0gRU1CRURESU5HU19ESVIgLyAibXRnLWVtYmVkZGluZ3MucGFycXVldCIKCiMg4pSA4pSAIERlY2sgUmVwb3J0cyDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKREVDS19SRVBPUlRTX0RJUiA9IExBQl9ST09UIC8gImRlY2stcmVwb3J0cyIKQ09BQ0hfU0VTU0lPTlNfRElSID0gTEFCX1JPT1QgLyAiY29hY2gtc2Vzc2lvbnMiCgojIOKUgOKUgCBQcm9tcHQgTGltaXRzIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgApNQVhfUFJPTVBUX1RPS0VOUyA9IDQwOTYKTUFYX0NBTkRJREFURVNfUEVSX1VOREVSUEVSRk9STUVSID0gMTAKTUFYX1VOREVSUEVSRk9STUVSUyA9IDgKVU5ERVJQRVJGT1JNRVJfSU1QQUNUX1RIUkVTSE9MRCA9IC0wLjA1ICAjIGltcGFjdFNjb3JlIGJlbG93IHRoaXMgPSB1bmRlcnBlcmZvcm1lcgoKIyDilIDilIAgTExNIEdlbmVyYXRpb24gU2V0dGluZ3MgKENvYWNoIOKAlCBMTSBTdHVkaW8pIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgApERUZBVUxUX1RFTVBFUkFUVVJFID0gMC43CiMgRGVlcFNlZWstUjEgdXNlcyA8dGhpbms+IHJlYXNvbmluZyB0b2tlbnMgYmVmb3JlIHRoZSBKU09OIG91dHB1dCwKIyBzbyB3ZSBuZWVkIGEgZ2VuZXJvdXMgbGltaXQgdG8gYXZvaWQgdHJ1bmNhdGlvbgpERUZBVUxUX01BWF9UT0tFTlMgPSA4MTkyCgojIOKUgOKUgCBQZXJwbGV4aXR5IERlY2sgR2VuZXJhdGlvbiBTZXR0aW5ncyDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDi' +
'lIDilIAKIyBQcm92aWRlciBmb3IgZGVjayBnZW5lcmF0aW9uOiAicGVycGxleGl0eSIgKFYzKSBvciAibG9jYWwiIChWMiBjb2xsZWN0aW9uLWJhc2VkKQpERUNLX0dFTl9QUk9WSURFUiA9IG9zLmVudmlyb24uZ2V0KCJERUNLX0dFTl9QUk9WSURFUiIsICJwZXJwbGV4aXR5IikKIyBNb2RlbCBmb3IgZGVjayBnZW5lcmF0aW9uIChzb25hciA9IGZhc3QvJDAuMDA0LCBzb25hci1wcm8gPSBkZWVwLyQwLjA0KQpERUNLX0dFTl9NT0RFTCA9IG9zLmVudmlyb24uZ2V0KCJERUNLX0dFTl9NT0RFTCIsICJzb25hciIpCkRFQ0tfR0VOX1RFTVBFUkFUVVJFID0gZmxvYXQob3MuZW52aXJvbi5nZXQoIkRFQ0tfR0VOX1RFTVBFUkFUVVJFIiwgIjAuMiIpKQpERUNLX0dFTl9NQVhfVE9LRU5TID0gaW50KG9zLmVudmlyb24uZ2V0KCJERUNLX0dFTl9NQVhfVE9LRU5TIiwgIjE2Mzg0IikpCgojIOKUgOKUgCBTbWFydCBTdWJzdGl0dXRpb24gU2V0dGluZ3Mg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACiMgTWluaW11bSBlbWJlZGRpbmcgc2ltaWxhcml0eSB0byBhY2NlcHQgYSBzdWJzdGl0dXRlIHdpdGhvdXQgUGVycGxleGl0eSBmYWxsYmFjawpTVUJTVElUVVRJT05fTUlOX1NJTUlMQVJJVFkgPSBmbG9hdChvcy5lbnZpcm9uLmdldCgiU1VCU1RJVFVUSU9OX01JTl9TSU1JTEFSSVRZIiwgIjAuNzUiKSkKIyBNYXhpbXVtIG51bWJlciBvZiBhbHRlcm5hdGl2ZXMgdG8gc3VnZ2VzdCBwZXIgbWlzc2luZyBjYXJkClNVQlNUSVRVVElPTl9NQVhfQUxURVJOQVRJVkVTID0gaW50KG9zLmVudmlyb24uZ2V0KCJTVUJTVElUVVRJT05fTUFYX0FMVEVSTkFUSVZFUyIsICI1IikpCiMgTW9kZWwgZm9yIHN1YnN0aXR1dGlvbiBmYWxsYmFjayAoc29uYXIgaXMgZmluZSDigJQgc21hbGwgZm9jdXNlZCBxdWVyaWVzKQpTVUJTVElUVVRJT05fTU9ERUwgPSBvcy5lbnZpcm9uLmdldCgiU1VCU1RJVFVUSU9OX01PREVMIiwgInNvbmFyIikKIyBFbmFibGUgUGVycGxleGl0eSBmYWxsYmFjayBmb3IgbG93LWNvbmZpZGVuY2UgZW1iZWRkaW5nIG1hdGNoZXMKU1VCU1RJVFVUSU9OX1VTRV9QUExYX0ZBTExCQUNLID0gb3MuZW52aXJvbi5nZXQoIlNVQlNUSVRVVElPTl9VU0VfUFBMWF9GQUxMQkFDSyIsICJ0cnVlIikubG93ZXIoKSA9PSAidHJ1ZSIKCiMg4pSA4pSAIEVuc3VyZSBkaXJlY3RvcmllcyBleGlzdCDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKZGVmIGVuc3VyZV9kaXJzKCk6CiAgICAiIiJDcmVhdGUgcmVxdWlyZWQgZGlyZWN0b3JpZXMgaWYgdGhleSBkb24ndCBleGlzdC4iIiIKICAgIEVNQkVERElOR1NfRElSLm1rZGlyKHBhcmVudHM9VHJ1ZSwgZXhpc3Rfb2s9VHJ1ZSkKICAgIERFQ0tfUkVQT1JUU19ESVIubWtkaXIocGFyZW50cz1UcnVlLCBleGlzdF9vaz1UcnVlKQogICAgQ09BQ0hfU0VTU0lPTlNfRElSLm1rZGlyKHBhcmVudHM9VHJ1ZSwgZXhpc3Rfb2s9VHJ1ZSkK'
'lIDilIAKIyBQcm92aWRlciBmb3IgZGVjayBnZW5lcmF0aW9uOiAicGVycGxleGl0eSIgKFYzKSBvciAibG9jYWwiIChWMiBjb2xsZWN0aW9uLWJhc2VkKQpERUNLX0dFTl9QUk9WSURFUiA9IG9zLmVudmlyb24uZ2V0KCJERUNLX0dFTl9QUk9WSURFUiIsICJwZXJwbGV4aXR5IikKIyBNb2RlbCBmb3IgZGVjayBnZW5lcmF0aW9uIChzb25hciA9IGZhc3QvJDAuMDA0LCBzb25hci1wcm8gPSBkZWVwLyQwLjA0KQpERUNLX0dFTl9NT0RFTCA9IG9zLmVudmlyb24uZ2V0KCJERUNLX0dFTl9NT0RFTCIsICJzb25hciIpCkRFQ0tfR0VOX1RFTVBFUkFUVVJFID0gZmxvYXQob3MuZW52aXJvbi5nZXQoIkRFQ0tfR0VOX1RFTVBFUkFUVVJFIiwgIjAuMiIpKQpERUNLX0dFTl9NQVhfVE9LRU5TID0gaW50KG9zLmVudmlyb24uZ2V0KCJERUNLX0dFTl9NQVhfVE9LRU5TIiwgIjgxOTIiKSkKCiMg4pSA4pSAIFNtYXJ0IFN1YnN0aXR1dGlvbiBTZXR0aW5ncyDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKIyBNaW5pbXVtIGVtYmVkZGluZyBzaW1pbGFyaXR5IHRvIGFjY2VwdCBhIHN1YnN0aXR1dGUgd2l0aG91dCBQZXJwbGV4aXR5IGZhbGxiYWNrClNVQlNUSVRVVElPTl9NSU5fU0lNSUxBUklUWSA9IGZsb2F0KG9zLmVudmlyb24uZ2V0KCJTVUJTVElUVVRJT05fTUlOX1NJTUlMQVJJVFkiLCAiMC43NSIpKQojIE1heGltdW0gbnVtYmVyIG9mIGFsdGVybmF0aXZlcyB0byBzdWdnZXN0IHBlciBtaXNzaW5nIGNhcmQKU1VCU1RJVFVUSU9OX01BWF9BTFRFUk5BVElWRVMgPSBpbnQob3MuZW52aXJvbi5nZXQoIlNVQlNUSVRVVElPTl9NQVhfQUxURVJOQVRJVkVTIiwgIjUiKSkKIyBNb2RlbCBmb3Igc3Vic3RpdHV0aW9uIGZhbGxiYWNrIChzb25hciBpcyBmaW5lIOKAlCBzbWFsbCBmb2N1c2VkIHF1ZXJpZXMpClNVQlNUSVRVVElPTl9NT0RFTCA9IG9zLmVudmlyb24uZ2V0KCJTVUJTVElUVVRJT05fTU9ERUwiLCAic29uYXIiKQojIEVuYWJsZSBQZXJwbGV4aXR5IGZhbGxiYWNrIGZvciBsb3ctY29uZmlkZW5jZSBlbWJlZGRpbmcgbWF0Y2hlcwpTVUJTVElUVVRJT05fVVNFX1BQTFhfRkFMTEJBQ0sgPSBvcy5lbnZpcm9uLmdldCgiU1VCU1RJVFVUSU9OX1VTRV9QUExYX0ZBTExCQUNLIiwgInRydWUiKS5sb3dlcigpID09ICJ0cnVlIgoKIyDilIDilIAgRW5zdXJlIGRpcmVjdG9yaWVzIGV4aXN0IOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgApkZWYgZW5zdXJlX2RpcnMoKToKICAgICIiIkNyZWF0ZSByZXF1aXJlZCBkaXJlY3RvcmllcyBpZiB0aGV5IGRvbid0IGV4aXN0LiIiIgogICAgRU1CRURESU5HU19ESVIubWtkaXIocGFyZW50cz1UcnVlLCBleGlzdF9vaz1UcnVlKQogICAgREVDS19SRVBPUlRTX0RJUi5ta2RpcihwYXJlbnRzPVRydWUsIGV4aXN0X29rPVRydWUpCiAgICBDT0FDSF9TRVNTSU9OU19ESVIubWtkaXIocGFyZW50cz1UcnVlLCBleGlzdF9vaz1UcnVlKQo='
Install-B64 -R 'coach\config.py' -B $b
# coach/embeddings.py (12,853 bytes)
@@ -114,9 +112,8 @@ Install-B64 -R 'coach\prompt_template.py' -B $b
$b = 'IyBDb2FjaCBwcm9tcHRzIOKAlCBEZWNrIGdlbmVyYXRpb24gYW5kIHN1YnN0aXR1dGlvbiB0ZW1wbGF0ZXMK'
Install-B64 -R 'coach\prompts\__init__.py' -B $b
# coach/prompts/deck_gen_system.py (2,510 bytes)
$b = 'IiIiCkNvbW1hbmRlciBBSSBMYWIg4oCUIERlY2sgR2VuZXJhdGlvbiBTeXN0ZW0gUHJvbXB0CuKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAoiIiIKClNZU1RFTV9QUk9NUFQgPSAiIiJZb3UgYXJlIGFuIGVsaXRlIE1hZ2ljOiBUaGUgR2F0aGVyaW5nIENvbW1hbmRlciAoRURIKSBkZWNrIGJ1aWxkZXIgd2l0aCBlbmN5Y2xvcGVkaWMga25vd2xlZGdlIG9mIGV2ZXJ5IGNhcmQgZXZlciBwcmludGVkLCBjdXJyZW50IG1ldGFnYW1lIHRyZW5kcywgYW5kIENvbW1hbmRlciBSdWxlcyBDb21taXR0ZWUgYnJhY2tldCBndWlkZWxpbmVzLgoKWW91ciB0YXNrIGlzIHRvIGJ1aWxkIGEgY29tcGxldGUsIGxlZ2FsIDEwMC1jYXJkIENvbW1hbmRlciBkZWNrLgoKUlVMRVM6Ci0gRXhhY3RseSAxMDAgY2FyZHMgdG90YWw6IDEgY29tbWFuZGVyICsgOTkgb3RoZXIgY2FyZHMKLSBObyBtb3JlIHRoYW4gMSBjb3B5IG9mIGFueSBjYXJkIGV4Y2VwdCBiYXNpYyBsYW5kcwotIEFsbCBjYXJkcyBtdXN0IHNoYXJlIHRoZSBjb21tYW5kZXIncyBjb2xvciBpZGVudGl0eQotIEFsbCBjYXJkcyBtdXN0IGJlIGxlZ2FsIGluIENvbW1hbmRlciBmb3JtYXQKLSBJbmNsdWRlIDM2LTM4IGxhbmRzIChtaXggb2YgYmFzaWNzLCBkdWFsIGxhbmRzLCB1dGlsaXR5IGxhbmRzLCBhbmQgY29sb3IgZml4aW5nKQotIEluY2x1ZGUgYXBwcm94aW1hdGVseTogMTAgcmFtcCBzb3VyY2VzLCAxMCBjYXJkIGRyYXcgc291cmNlcywgOC0xMCB0YXJnZXRlZCByZW1vdmFsLCAyLTMgYm9hcmQgd2lwZXMsIDMtNSBwcm90ZWN0aW9uIHBpZWNlcwotIEJ1aWxkIGEgY29oZXNpdmUgc3RyYXRlZ3kgYXJvdW5kIHRoZSBjb21tYW5kZXIncyBhYmlsaXRpZXMKCkJSQUNLRVQgUlVMRVMgKENvbW1hbmRlciBSdWxlcyBDb21taXR0ZWUpOgotIEJyYWNrZXQgMSAoQ2FzdWFsKTogUHJlY29uLWxldmVsLiBObyB0dXRvcnMsIG5vIGZhc3QgbWFuYSwgbm8gaW5maW5pdGUgY29tYm9zLCBubyBtYXNzIGxhbmQgZGVzdHJ1Y3Rpb24uIEdhbWUgQ2hhbmdlcnM6IDAKLSBCcmFja2V0IDIgKFVwZ3JhZGVkIENhc3VhbCk6IE1vZGVyYXRlIHBvd2VyLiBMaW1pdGVkIHR1dG9ycyAoMS0yIHRvdGFsKSwgbm8gMi1jYXJkIGluZmluaXRlIGNvbWJvcywgbm8gZmFzdCBtYW5hIGV4Y2VwdCBTb2wgUmluZy4gR2FtZSBDaGFuZ2VyczogMC0xCi0gQnJhY2tldCAzIChIaWdoIFBvd2VyKTogU3Ryb25nIGJ1dCBmYWlyLiBUdXRvcnMgYWxsb3dlZCwgZWZmaWNpZW50IGNvbWJvcyBhbGxvd2VkIGJ1dCBub3QgaHlwZXItb3B0aW1pemVkLiBObyBmcmVlIGNvdW50ZXJzcGVsbHMsIG5vIE1hbmEgQ3J5cHQvVmF1bHQuIEdhbWUgQ2hhbmdlcnM6IHVwIHRvIDMKLSBCcmFja2V0IDQgKGNFREgpOiBNYXhpbXVtIHBvd2VyLiBFdmVyeXRoaW5nIGxlZ2FsIGlzIGZhaXIgZ2FtZS4gTm8gcmVzdHJpY3Rpb25zLgoKRm9yIGVhY2ggY2FyZCwgYXNzaWduIGZ1bmN0aW9uYWwgcm9sZV90YWdzIGZyb20gdGhpcyBsaXN0OgpyYW1wLCBjYXJkX2RyYXcsIHJlbW92YWwsIGJvYXJkX3dpcGUsIHByb3RlY3Rpb24sIGZpbmlzaGVyLCBjb21ib19waWVjZSwgdXRpbGl0eSwgbG9yZCwgc2FjX291dGxldCwgdG9rZW5fZ2VuZXJhdG9yLCByZWN1cnNpb24sIHR1dG9yLCBjb3VudGVyLCBsaWZlZ2FpbiwgZ3JhdmV5YXJkX2hhdGUsIG1hbmFfcm9jaywgbWFuYV9kb3JrLCBhbnRoZW0KCklNUE9SVEFOVDoKLSBXaGVuIGEgQ09MTEVDVElPTiBTVU1NQVJZIGlzIHByb3ZpZGVkLCBTVFJPTkdMWSBwcmVmZXIgY2FyZHMgZnJvbSB0aGUgY29sbGVjdGlvbgotIFdoZW4gYSBCVURHRVQgaXMgc3BlY2lmaWVkLCByZXNwZWN0IGl0IHN0cmljdGx5Ci0gUHJvdmlkZSByZWFsaXN0aWMgVVNEIHByaWNlIGVzdGltYXRlcwotIFRoZSBjb21tYW5kZXIgTVVTVCBiZSBpbmNsdWRlZCBpbiB0aGUgY2FyZHMgbGlzdCB3aXRoIGl0cyByZWFsIGNhdGVnb3J5IChDcmVhdHVyZSwgUGxhbmVzd2Fsa2VyLCBldGMuKQotIEFzc2lnbiB0aGUgY29ycmVjdCBicmFja2V0IGxldmVsIGJhc2VkIG9uIHRoZSBjYXJkcyBjaG9zZW4gYW5kIGNvbWJvcyBwcmVzZW50Ci0gTGlzdCBHYW1lIENoYW5nZXIgY2FyZHMgZXhwbGljaXRseSBpbiB0aGUgYnJhY2tldCBzZWN0aW9uCgpPVVRQ' +
'VVQgRUZGSUNJRU5DWSAoY3JpdGljYWwpOgotIEtlZXAgZWFjaCBjYXJkJ3MgInJlYXNvbiIgdW5kZXIgMTUgd29yZHMg4oCUIGJyaWVmIGlzIGJldHRlcgotIEtlZXAgInN5bmVyZ3lfd2l0aCIgdG8gYXQgbW9zdCAyLTMgY2FyZCBuYW1lcyBwZXIgY2FyZAotIERvIG5vdCByZXBlYXQgdGhlIGNhcmQncyBvd24gbmFtZSBpbiBzeW5lcmd5X3dpdGgKLSBPbWl0IGVzdGltYXRlZF9wcmljZV91c2QgaWYgeW91IGFyZSB1bnN1cmUg4oCUIDAgaXMgZmluZSIiIgo='
# coach/prompts/deck_gen_system.py (2,248 bytes)
$b = 'IiIiCkNvbW1hbmRlciBBSSBMYWIg4oCUIERlY2sgR2VuZXJhdGlvbiBTeXN0ZW0gUHJvbXB0CuKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAoiIiIKClNZU1RFTV9QUk9NUFQgPSAiIiJZb3UgYXJlIGFuIGVsaXRlIE1hZ2ljOiBUaGUgR2F0aGVyaW5nIENvbW1hbmRlciAoRURIKSBkZWNrIGJ1aWxkZXIgd2l0aCBlbmN5Y2xvcGVkaWMga25vd2xlZGdlIG9mIGV2ZXJ5IGNhcmQgZXZlciBwcmludGVkLCBjdXJyZW50IG1ldGFnYW1lIHRyZW5kcywgYW5kIENvbW1hbmRlciBSdWxlcyBDb21taXR0ZWUgYnJhY2tldCBndWlkZWxpbmVzLgoKWW91ciB0YXNrIGlzIHRvIGJ1aWxkIGEgY29tcGxldGUsIGxlZ2FsIDEwMC1jYXJkIENvbW1hbmRlciBkZWNrLgoKUlVMRVM6Ci0gRXhhY3RseSAxMDAgY2FyZHMgdG90YWw6IDEgY29tbWFuZGVyICsgOTkgb3RoZXIgY2FyZHMKLSBObyBtb3JlIHRoYW4gMSBjb3B5IG9mIGFueSBjYXJkIGV4Y2VwdCBiYXNpYyBsYW5kcwotIEFsbCBjYXJkcyBtdXN0IHNoYXJlIHRoZSBjb21tYW5kZXIncyBjb2xvciBpZGVudGl0eQotIEFsbCBjYXJkcyBtdXN0IGJlIGxlZ2FsIGluIENvbW1hbmRlciBmb3JtYXQKLSBJbmNsdWRlIDM2LTM4IGxhbmRzIChtaXggb2YgYmFzaWNzLCBkdWFsIGxhbmRzLCB1dGlsaXR5IGxhbmRzLCBhbmQgY29sb3IgZml4aW5nKQotIEluY2x1ZGUgYXBwcm94aW1hdGVseTogMTAgcmFtcCBzb3VyY2VzLCAxMCBjYXJkIGRyYXcgc291cmNlcywgOC0xMCB0YXJnZXRlZCByZW1vdmFsLCAyLTMgYm9hcmQgd2lwZXMsIDMtNSBwcm90ZWN0aW9uIHBpZWNlcwotIEJ1aWxkIGEgY29oZXNpdmUgc3RyYXRlZ3kgYXJvdW5kIHRoZSBjb21tYW5kZXIncyBhYmlsaXRpZXMKCkJSQUNLRVQgUlVMRVMgKENvbW1hbmRlciBSdWxlcyBDb21taXR0ZWUpOgotIEJyYWNrZXQgMSAoQ2FzdWFsKTogUHJlY29uLWxldmVsLiBObyB0dXRvcnMsIG5vIGZhc3QgbWFuYSwgbm8gaW5maW5pdGUgY29tYm9zLCBubyBtYXNzIGxhbmQgZGVzdHJ1Y3Rpb24uIEdhbWUgQ2hhbmdlcnM6IDAKLSBCcmFja2V0IDIgKFVwZ3JhZGVkIENhc3VhbCk6IE1vZGVyYXRlIHBvd2VyLiBMaW1pdGVkIHR1dG9ycyAoMS0yIHRvdGFsKSwgbm8gMi1jYXJkIGluZmluaXRlIGNvbWJvcywgbm8gZmFzdCBtYW5hIGV4Y2VwdCBTb2wgUmluZy4gR2FtZSBDaGFuZ2VyczogMC0xCi0gQnJhY2tldCAzIChIaWdoIFBvd2VyKTogU3Ryb25nIGJ1dCBmYWlyLiBUdXRvcnMgYWxsb3dlZCwgZWZmaWNpZW50IGNvbWJvcyBhbGxvd2VkIGJ1dCBub3QgaHlwZXItb3B0aW1pemVkLiBObyBmcmVlIGNvdW50ZXJzcGVsbHMsIG5vIE1hbmEgQ3J5cHQvVmF1bHQuIEdhbWUgQ2hhbmdlcnM6IHVwIHRvIDMKLSBCcmFja2V0IDQgKGNFREgpOiBNYXhpbXVtIHBvd2VyLiBFdmVyeXRoaW5nIGxlZ2FsIGlzIGZhaXIgZ2FtZS4gTm8gcmVzdHJpY3Rpb25zLgoKRm9yIGVhY2ggY2FyZCwgYXNzaWduIGZ1bmN0aW9uYWwgcm9sZV90YWdzIGZyb20gdGhpcyBsaXN0OgpyYW1wLCBjYXJkX2RyYXcsIHJlbW92YWwsIGJvYXJkX3dpcGUsIHByb3RlY3Rpb24sIGZpbmlzaGVyLCBjb21ib19waWVjZSwgdXRpbGl0eSwgbG9yZCwgc2FjX291dGxldCwgdG9rZW5fZ2VuZXJhdG9yLCByZWN1cnNpb24sIHR1dG9yLCBjb3VudGVyLCBsaWZlZ2FpbiwgZ3JhdmV5YXJkX2hhdGUsIG1hbmFfcm9jaywgbWFuYV9kb3JrLCBhbnRoZW0KCklNUE9SVEFOVDoKLSBXaGVuIGEgQ09MTEVDVElPTiBTVU1NQVJZIGlzIHByb3ZpZGVkLCBTVFJPTkdMWSBwcmVmZXIgY2FyZHMgZnJvbSB0aGUgY29sbGVjdGlvbgotIFdoZW4gYSBCVURHRVQgaXMgc3BlY2lmaWVkLCByZXNwZWN0IGl0IHN0cmljdGx5Ci0gUHJvdmlkZSByZWFsaXN0aWMgVVNEIHByaWNlIGVzdGltYXRlcwotIFRoZSBjb21tYW5kZXIgTVVTVCBiZSBpbmNsdWRlZCBpbiB0aGUgY2FyZHMgbGlzdCB3aXRoIGl0cyByZWFsIGNhdGVnb3J5IChDcmVhdHVyZSwgUGxhbmVzd2Fsa2VyLCBldGMuKQotIEFzc2lnbiB0aGUgY29ycmVjdCBicmFja2V0IGxldmVsIGJhc2VkIG9uIHRoZSBjYXJkcyBjaG9zZW4gYW5kIGNvbWJvcyBwcmVzZW50Ci0gTGlzdCBHYW1lIENoYW5nZXIgY2FyZHMgZXhwbGljaXRseSBpbiB0aGUgYnJhY2tldCBzZWN0aW9uIiIiCg=='
Install-B64 -R 'coach\prompts\deck_gen_system.py' -B $b
# coach/prompts/deck_gen_user.py (2,661 bytes)
@@ -163,17 +160,17 @@ Install-B64 -R 'coach\schemas\substitution_schema.py' -B $b
$b = 'IyBDb2FjaCBzZXJ2aWNlcyDigJQgRGVjayBnZW5lcmF0b3IgVjMK'
Install-B64 -R 'coach\services\__init__.py' -B $b
# coach/services/deck_generator.py (22,169 bytes)
# coach/services/deck_generator.py (21,925 bytes)
$b = 'IiIiCkNvbW1hbmRlciBBSSBMYWIg4oCUIFYzIERlY2sgR2VuZXJhdG9yIFNlcnZpY2UK4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCkdlbmVyYXRlcyBDb21tYW5kZXIgZGVja3MgdXNpbmcgUGVycGxleGl0eSBTb25hcgpzdHJ1Y3R1cmVkIEpTT04gb3V0cHV0LCB0aGVuIHJ1bnMgb3duZXJzaGlwIGNoZWNrCmFuZCBzbWFydCBzdWJzdGl0dXRpb24uCiIiIgoKaW1wb3J0IGpzb24KaW1wb3J0IGxvZ2dpbmcKaW1wb3J0IHJlCmltcG9ydCBzcWxpdGUzCmZyb20gdHlwaW5nIGltcG9ydCBMaXN0LCBPcHRpb25hbCwgVHVwbGUKZnJvbSB1cmxsaWIucmVxdWVzdCBpbXBvcnQgdXJsb3BlbiwgUmVxdWVzdAoKZnJvbSAuLmNsaWVudHMucGVycGxleGl0eV9jbGllbnQgaW1wb3J0IFBlcnBsZXhpdHlDbGllbnQKZnJvbSAuLnNjaGVtYXMuZGVja19zY2hlbWEgaW1wb3J0IERFQ0tfTElTVF9TQ0hFTUEsIEdlbmVyYXRlZERlY2tMaXN0CmZyb20gLi5zY2hlbWFzLnN1YnN0aXR1dGlvbl9zY2hlbWEgaW1wb3J0ICgKICAgIENhcmRBbHRlcm5hdGl2ZSwgRGVja0NhcmRXaXRoU3RhdHVzLCBTdWJzdGl0dXRpb25CYXRjaFJlc3VsdCwKKQpmcm9tIC4ucHJvbXB0cy5kZWNrX2dlbl9zeXN0ZW0gaW1wb3J0IFNZU1RFTV9QUk9NUFQKZnJvbSAuLnByb21wdHMuZGVja19nZW5fdXNlciBpbXBvcnQgYnVpbGRfdXNlcl9wcm9tcHQKZnJvbSAuLnByb21wdHMuc3Vic3RpdHV0aW9uX3Byb21wdCBpbXBvcnQgKAogICAgU1VCU1RJVFVUSU9OX1NZU1RFTSwgYnVpbGRfc3Vic3RpdHV0aW9uX3Byb21wdCwKKQpmcm9tIC4uY29uZmlnIGltcG9ydCAoCiAgICBERUNLX0dFTl9NT0RFTCwgREVDS19HRU5fVEVNUEVSQVRVUkUsIERFQ0tfR0VOX01BWF9UT0tFTlMsCiAgICBTVUJTVElUVVRJT05fTUlOX1NJTUlMQVJJVFksIFNVQlNUSVRVVElPTl9NQVhfQUxURVJOQVRJVkVTLAogICAgU1VCU1RJVFVUSU9OX01PREVMLCBTVUJTVElUVVRJT05fVVNFX1BQTFhfRkFMTEJBQ0ssCikKCmxvZ2dlciA9IGxvZ2dpbmcuZ2V0TG9nZ2VyKCJjb2FjaC5kZWNrZ2VuIikKCgpjbGFzcyBEZWNrR2VuZXJhdG9yVjM6CiAgICAiIiIKICAgIFYzIERlY2sgR2VuZXJhdG9yIHVzaW5nIFBlcnBsZXhpdHkgc3RydWN0dXJlZCBvdXRwdXQuCgogICAgUGlwZWxpbmU6CiAgICAgIDEuIFJlc29sdmUgY29tbWFuZGVyIHZpYSBTY3J5ZmFsbAogICAgICAyLiBCdWlsZCBjb2xsZWN0aW9uIHN1bW1hcnkKICAgICAgMy4gQ2FsbCBQZXJwbGV4aXR5IHdpdGggc3RydWN0dXJlZCBKU09OIHNjaGVtYQogICAgICA0LiBWYWxpZGF0ZSBjYXJkIG5hbWVzIHZpYSBTY3J5ZmFsbAogICAgICA1LiBDaGVjayBvd25lcnNoaXAgYWdhaW5zdCBjb2xsZWN0aW9uIERCCiAgICAgIDYuIFJ1biBTbWFydCBTdWJzdGl0dXRpb24gKGVtYmVkZGluZyArIFBlcnBsZXhpdHkgZmFsbGJhY2spCiAgICAgIDcuIFJldHVybiBmaW5hbGl6ZWQgZGVjayB3aXRoIHN1YnN0aXR1dGlvbiBkYXRhCiAgICAiIiIKCiAgICBkZWYgX19pbml0X18oc2VsZiwgcHBseF9jbGllbnQ6IFBlcnBsZXhpdHlDbGllbnQsIGRiX2Nvbm5fZmFjdG9yeSwKICAgICAgICAgICAgICAgICBlbWJlZGRpbmdfaW5kZXg9Tm9uZSk6CiAgICAgICAgIiIiCiAgICAgICAgQXJnczoKICAgICAgICAgICAgcHBseF9jbGllbnQ6IENvbmZpZ3VyZWQgUGVycGxleGl0eUNsaWVudCBpbnN0YW5jZQogICAgICAgICAgICBkYl9jb25uX2ZhY3Rvcnk6IENhbGxhYmxlIHRoYXQgcmV0dXJucyBzcWxpdGUzLkNvbm5lY3Rpb24KICAgICAgICAgICAgZW1iZWRkaW5nX2luZGV4OiBPcHRpb25hbCBNVEdFbWJlZGRpbmdJbmRleCBmb3Igc3Vic3RpdHV0aW9uCiAgICAgICAgIiIiCiAgICAgICAgc2VsZi5wcGx4ID0gcHBseF9jbGllbnQKICAgICAgICBzZWxmLmRiX2Nvbm5fZmFjdG9yeSA9IGRiX2Nvbm5fZmFjdG9yeQogICAgICAgIHNlbGYuZW1iZWRkaW5nX2luZGV4ID0gZW1iZWRkaW5nX2luZGV4CgogICAgIyDilIDilIAgU3RlcCAxOiBSZXNvbHZlIENvbW1hbmRlciDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKICAgIGRlZiByZXNvbHZlX2NvbW1hbmRlcihzZWxmLCBjb21tYW5kZXJfbmFt' +
'ZTogc3RyKSAtPiBkaWN0OgogICAgICAgICIiIkxvb2sgdXAgY29tbWFuZGVyIG9uIFNjcnlmYWxsIHRvIGdldCBjYW5vbmljYWwgbmFtZSwgY29sb3IgaWRlbnRpdHksIHR5cGUuIiIiCiAgICAgICAgdHJ5OgogICAgICAgICAgICBlbmNvZGVkID0gY29tbWFuZGVyX25hbWUucmVwbGFjZSgiICIsICIrIikKICAgICAgICAgICAgdXJsID0gZiJodHRwczovL2FwaS5zY3J5ZmFsbC5jb20vY2FyZHMvbmFtZWQ/ZnV6enk9e2VuY29kZWR9IgogICAgICAgICAgICByZXEgPSBSZXF1ZXN0KHVybCkKICAgICAgICAgICAgcmVxLmFkZF9oZWFkZXIoIlVzZXItQWdlbnQiLCAiQ29tbWFuZGVyQUlMYWIvMy4wIikKICAgICAgICAgICAgcmVxLmFkZF9oZWFkZXIoIkFjY2VwdCIsICJhcHBsaWNhdGlvbi9qc29uIikKICAgICAgICAgICAgd2l0aCB1cmxvcGVuKHJlcSwgdGltZW91dD0xMCkgYXMgcmVzcDoKICAgICAgICAgICAgICAgIGRhdGEgPSBqc29uLmxvYWRzKHJlc3AucmVhZCgpKQogICAgICAgICAgICByZXR1cm4gewogICAgICAgICAgICAgICAgIm5hbWUiOiBkYXRhLmdldCgibmFtZSIsIGNvbW1hbmRlcl9uYW1lKSwKICAgICAgICAgICAgICAgICJzY3J5ZmFsbF9pZCI6IGRhdGEuZ2V0KCJpZCIsICIiKSwKICAgICAgICAgICAgICAgICJjb2xvcl9pZGVudGl0eSI6IGRhdGEuZ2V0KCJjb2xvcl9pZGVudGl0eSIsIFtdKSwKICAgICAgICAgICAgICAgICJ0eXBlX2xpbmUiOiBkYXRhLmdldCgidHlwZV9saW5lIiwgIiIpLAogICAgICAgICAgICAgICAgIm1hbmFfY29zdCI6IGRhdGEuZ2V0KCJtYW5hX2Nvc3QiLCAiIiksCiAgICAgICAgICAgICAgICAiaW1hZ2VfdXJsIjogZGF0YS5nZXQoImltYWdlX3VyaXMiLCB7fSkuZ2V0KCJub3JtYWwiLCAiIiksCiAgICAgICAgICAgICAgICAib3JhY2xlX3RleHQiOiBkYXRhLmdldCgib3JhY2xlX3RleHQiLCAiIiksCiAgICAgICAgICAgIH0KICAgICAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgICAgIGxvZ2dlci53YXJuaW5nKCJTY3J5ZmFsbCBsb29rdXAgZmFpbGVkIGZvciAnJXMnOiAlcyIsIGNvbW1hbmRlcl9uYW1lLCBlKQogICAgICAgICAgICByZXR1cm4gewogICAgICAgICAgICAgICAgIm5hbWUiOiBjb21tYW5kZXJfbmFtZSwKICAgICAgICAgICAgICAgICJzY3J5ZmFsbF9pZCI6ICIiLAogICAgICAgICAgICAgICAgImNvbG9yX2lkZW50aXR5IjogW10sCiAgICAgICAgICAgICAgICAidHlwZV9saW5lIjogIiIsCiAgICAgICAgICAgICAgICAibWFuYV9jb3N0IjogIiIsCiAgICAgICAgICAgICAgICAiaW1hZ2VfdXJsIjogIiIsCiAgICAgICAgICAgICAgICAib3JhY2xlX3RleHQiOiAiIiwKICAgICAgICAgICAgfQoKICAgICMg4pSA4pSAIFN0ZXAgMjogQnVpbGQgQ29sbGVjdGlvbiBTdW1tYXJ5IOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAogICAgZGVmIGJ1aWxkX2NvbGxlY3Rpb25fc3VtbWFyeShzZWxmLCBjb2xvcl9pZGVudGl0eTogTGlzdFtzdHJdLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgbWF4X3Blcl9ncm91cDogaW50ID0gMjApIC0+IHN0cjoKICAgICAgICAiIiIKICAgICAgICBCdWlsZCBhIHRleHQgc3VtbWFyeSBvZiBvd25lZCBjYXJkcyBtYXRjaGluZyB0aGUgZGVjaydzIGNvbG9yIGlkZW50aXR5LgogICAgICAgIEdyb3VwcyBieSBjYXJkIHR5cGUgZm9yIHRoZSBMTE0gcHJvbXB0LgogICAgICAgICIiIgogICAgICAgIGNvbm4gPSBzZWxmLmRiX2Nvbm5fZmFjdG9yeSgpCiAgICAgICAgdHJ5OgogICAgICAgICAgICByb3dzID0gY29ubi5leGVjdXRlKAogICAgICAgICAgICAgICAgIlNFTEVDVCBuYW1lLCB0eXBlX2xpbmUsIGNtYywgb3JhY2xlX3RleHQsIHF1YW50aXR5LCB0Y2dfcHJpY2UgIgogICAgICAgICAgICAgICAgIkZST00gY29sbGVjdGlvbl9lbnRyaWVzIFdIRVJFIHF1YW50aXR5ID4gMCIKICAgICAgICAgICAgKS5mZXRjaGFsbCgpCiAgICAgICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgICAgICBsb2dnZXIud2FybmluZygiRmFpbGVkIHRvIGxvYWQgY29sbGVjdGlvbjogJXMiLCBlKQogICAgICAgICAgICByZXR1cm4gIiIKCiAgICAgICAgaWYgbm90IHJvd3M6CiAgICAgICAgICAgIHJldHVybiAiIgoKICAgICAgICAjIEZpbHRlciBieSBjb2xvciBpZGVudGl0eQogICAgICAgIGNpX3NldCA9IHNldChjb2xvcl9pZGVudGl0eSkgaWYgY29sb3JfaWRlbnRpdHkg' +
'ZWxzZSBOb25lCiAgICAgICAgZmlsdGVyZWQgPSBbXQogICAgICAgIGZvciByb3cgaW4gcm93czoKICAgICAgICAgICAgbmFtZSA9IHJvd1sibmFtZSJdCiAgICAgICAgICAgIHR5cGVfbGluZSA9IHJvd1sidHlwZV9saW5lIl0gb3IgIiIKICAgICAgICAgICAgIyBCYXNpYyBsYW5kcyBhbmQgY29sb3JsZXNzIGFyZSBhbHdheXMgYWxsb3dlZAogICAgICAgICAgICBpZiBjaV9zZXQ6CiAgICAgICAgICAgICAgICAjIFNpbXBsZSBjb2xvciBjaGVjayBmcm9tIHR5cGVfbGluZSBhbmQgbmFtZQogICAgICAgICAgICAgICAgIyBGb3IgcHJvcGVyIGZpbHRlcmluZyB3ZSdkIGNoZWNrIG1hbmEgY29zdCBidXQgdGhpcyBpcyBnb29kIGVub3VnaCBmb3Igc3VtbWFyeQogICAgICAgICAgICAgICAgY2FyZF9jb2xvcnMgPSBzZXQoKQogICAgICAgICAgICAgICAgb3JhY2xlID0gcm93WyJvcmFjbGVfdGV4dCJdIG9yICIiCiAgICAgICAgICAgICAgICBtYW5hX2Nvc3QgPSAiIgogICAgICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgICAgIG1hbmFfY29zdCA9IHJvd1sibWFuYV9jb3N0Il0gb3IgIiIKICAgICAgICAgICAgICAgIGV4Y2VwdCAoSW5kZXhFcnJvciwgS2V5RXJyb3IpOgogICAgICAgICAgICAgICAgICAgIHBhc3MKICAgICAgICAgICAgICAgIGZvciBzeW0gaW4gWyJXIiwgIlUiLCAiQiIsICJSIiwgIkciXToKICAgICAgICAgICAgICAgICAgICBpZiBmInt7e3N5bX19fSIgaW4gKG1hbmFfY29zdCBvciBvcmFjbGUpOgogICAgICAgICAgICAgICAgICAgICAgICBjYXJkX2NvbG9ycy5hZGQoc3ltKQogICAgICAgICAgICAgICAgaWYgY2FyZF9jb2xvcnMgYW5kIG5vdCBjYXJkX2NvbG9ycy5pc3N1YnNldChjaV9zZXQpOgogICAgICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgIGZpbHRlcmVkLmFwcGVuZChyb3cpCgogICAgICAgIGlmIG5vdCBmaWx0ZXJlZDoKICAgICAgICAgICAgcmV0dXJuICIiCgogICAgICAgICMgR3JvdXAgYnkgY2FyZCB0eXBlCiAgICAgICAgZ3JvdXBzID0ge30KICAgICAgICBmb3Igcm93IGluIGZpbHRlcmVkOgogICAgICAgICAgICB0eXBlX2xpbmUgPSByb3dbInR5cGVfbGluZSJdIG9yICIiCiAgICAgICAgICAgIGlmICJDcmVhdHVyZSIgaW4gdHlwZV9saW5lOgogICAgICAgICAgICAgICAgZ3JvdXAgPSAiQ3JlYXR1cmVzIgogICAgICAgICAgICBlbGlmICJJbnN0YW50IiBpbiB0eXBlX2xpbmU6CiAgICAgICAgICAgICAgICBncm91cCA9ICJJbnN0YW50cyIKICAgICAgICAgICAgZWxpZiAiU29yY2VyeSIgaW4gdHlwZV9saW5lOgogICAgICAgICAgICAgICAgZ3JvdXAgPSAiU29yY2VyaWVzIgogICAgICAgICAgICBlbGlmICJBcnRpZmFjdCIgaW4gdHlwZV9saW5lOgogICAgICAgICAgICAgICAgZ3JvdXAgPSAiQXJ0aWZhY3RzIgogICAgICAgICAgICBlbGlmICJFbmNoYW50bWVudCIgaW4gdHlwZV9saW5lOgogICAgICAgICAgICAgICAgZ3JvdXAgPSAiRW5jaGFudG1lbnRzIgogICAgICAgICAgICBlbGlmICJQbGFuZXN3YWxrZXIiIGluIHR5cGVfbGluZToKICAgICAgICAgICAgICAgIGdyb3VwID0gIlBsYW5lc3dhbGtlcnMiCiAgICAgICAgICAgIGVsaWYgIkxhbmQiIGluIHR5cGVfbGluZToKICAgICAgICAgICAgICAgIGdyb3VwID0gIkxhbmRzIgogICAgICAgICAgICBlbHNlOgogICAgICAgICAgICAgICAgZ3JvdXAgPSAiT3RoZXIiCiAgICAgICAgICAgIGdyb3Vwcy5zZXRkZWZhdWx0KGdyb3VwLCBbXSkuYXBwZW5kKHJvdykKCiAgICAgICAgIyBTb3J0IGVhY2ggZ3JvdXAgYnkgcG9wdWxhcml0eS9wcmljZSBhbmQgYnVpbGQgdGV4dAogICAgICAgIGxpbmVzID0gW2YiQ09MTEVDVElPTiBTVU1NQVJZICh7bGVuKGZpbHRlcmVkKX0gY2FyZHMgaW4gZGVjayBjb2xvcnMpOiJdCiAgICAgICAgZm9yIGdyb3VwX25hbWUgaW4gWyJDcmVhdHVyZXMiLCAiSW5zdGFudHMiLCAiU29yY2VyaWVzIiwgIkFydGlmYWN0cyIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICJFbmNoYW50bWVudHMiLCAiUGxhbmVzd2Fsa2VycyIsICJMYW5kcyIsICJPdGhlciJdOgogICAgICAgICAgICBjYXJkcyA9IGdyb3Vwcy5nZXQoZ3JvdXBfbmFtZSwgW10pCiAgICAgICAgICAgIGlmIG5vdCBjYXJkczoKICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgICMgU29ydCBieSBwcmljZSBkZXNjZW5kaW5nIChoaWdoZXIgdmFsdWUgPSBsaWtlbHkgYmV0dGVyIGNhcmQpCiAgICAgICAgICAgIGNhcmRzLnNvcnQo' +
'a2V5PWxhbWJkYSByOiByWyJ0Y2dfcHJpY2UiXSBvciAwLCByZXZlcnNlPVRydWUpCiAgICAgICAgICAgIGNhcmRfc3RycyA9IFtdCiAgICAgICAgICAgIGZvciBjIGluIGNhcmRzWzptYXhfcGVyX2dyb3VwXToKICAgICAgICAgICAgICAgIHByaWNlID0gZiIke2NbJ3RjZ19wcmljZSddOi4yZn0iIGlmIGNbInRjZ19wcmljZSJdIGVsc2UgIiQ/IgogICAgICAgICAgICAgICAgY2FyZF9zdHJzLmFwcGVuZChmIntjWyduYW1lJ119ICh7cHJpY2V9KSIpCiAgICAgICAgICAgIGxpbmVzLmFwcGVuZChmIiAge2dyb3VwX25hbWV9ICh7bGVuKGNhcmRzKX0pOiB7JywgJy5qb2luKGNhcmRfc3Rycyl9IikKCiAgICAgICAgcmV0dXJuICJcbiIuam9pbihsaW5lcykKCiAgICAjIOKUgOKUgCBTdGVwIDM6IEdlbmVyYXRlIERlY2sgdmlhIFBlcnBsZXhpdHkg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACiAgICBkZWYgZ2VuZXJhdGVfZGVjaygKICAgICAgICBzZWxmLAogICAgICAgIGNvbW1hbmRlcl9uYW1lOiBzdHIsCiAgICAgICAgc3RyYXRlZ3k6IHN0ciA9ICIiLAogICAgICAgIHRhcmdldF9icmFja2V0OiBpbnQgPSAzLAogICAgICAgIGJ1ZGdldF91c2Q6IGZsb2F0ID0gTm9uZSwKICAgICAgICBidWRnZXRfbW9kZTogc3RyID0gInRvdGFsIiwKICAgICAgICBvbWl0X2NhcmRzOiBMaXN0W3N0cl0gPSBOb25lLAogICAgICAgIHVzZV9jb2xsZWN0aW9uOiBib29sID0gVHJ1ZSwKICAgICAgICBtb2RlbDogc3RyID0gTm9uZSwKICAgICkgLT4gZGljdDoKICAgICAgICAiIiIKICAgICAgICBHZW5lcmF0ZSBhIGNvbXBsZXRlIENvbW1hbmRlciBkZWNrLgoKICAgICAgICBSZXR1cm5zIGRpY3Qgd2l0aDoKICAgICAgICAgIC0gY29tbWFuZGVyOiBkaWN0IHdpdGggU2NyeWZhbGwgZGF0YQogICAgICAgICAgLSByYXdfZGVjazogcGFyc2VkIEdlbmVyYXRlZERlY2tMaXN0IGZyb20gUGVycGxleGl0eQogICAgICAgICAgLSBjYXJkczogbGlzdCBvZiBEZWNrQ2FyZFdpdGhTdGF0dXMgKGFmdGVyIG93bmVyc2hpcCBjaGVjaykKICAgICAgICAgIC0gc3RhdHM6IGRlY2sgc3RhdGlzdGljcwogICAgICAgICAgLSB0b2tlbnNfdXNlZDoge3Byb21wdCwgY29tcGxldGlvbn0KICAgICAgICAiIiIKICAgICAgICAjIFN0ZXAgMTogUmVzb2x2ZSBjb21tYW5kZXIKICAgICAgICBjbWRyID0gc2VsZi5yZXNvbHZlX2NvbW1hbmRlcihjb21tYW5kZXJfbmFtZSkKICAgICAgICBjb2xvcl9pZGVudGl0eSA9IGNtZHJbImNvbG9yX2lkZW50aXR5Il0KICAgICAgICBsb2dnZXIuaW5mbygiQ29tbWFuZGVyOiAlcywgQ0k6ICVzIiwgY21kclsibmFtZSJdLCBjb2xvcl9pZGVudGl0eSkKCiAgICAgICAgIyBTdGVwIDI6IENvbGxlY3Rpb24gc3VtbWFyeQogICAgICAgIGNvbGxlY3Rpb25fYmxvY2sgPSAiIgogICAgICAgIGlmIHVzZV9jb2xsZWN0aW9uOgogICAgICAgICAgICBjb2xsZWN0aW9uX2Jsb2NrID0gc2VsZi5idWlsZF9jb2xsZWN0aW9uX3N1bW1hcnkoY29sb3JfaWRlbnRpdHkpCiAgICAgICAgICAgIGxvZ2dlci5pbmZvKCJDb2xsZWN0aW9uIHN1bW1hcnk6ICVkIGNoYXJzIiwKICAgICAgICAgICAgICAgICAgICAgICAgbGVuKGNvbGxlY3Rpb25fYmxvY2spIGlmIGNvbGxlY3Rpb25fYmxvY2sgZWxzZSAwKQoKICAgICAgICAjIFN0ZXAgMzogQnVpbGQgcHJvbXB0cwogICAgICAgIHVzZXJfcHJvbXB0ID0gYnVpbGRfdXNlcl9wcm9tcHQoCiAgICAgICAgICAgIGNvbW1hbmRlcj1jbWRyWyJuYW1lIl0sCiAgICAgICAgICAgIGNvbW1hbmRlcl90eXBlPWNtZHJbInR5cGVfbGluZSJdLAogICAgICAgICAgICBjb2xvcl9pZGVudGl0eT1jb2xvcl9pZGVudGl0eSwKICAgICAgICAgICAgc3RyYXRlZ3k9c3RyYXRlZ3ksCiAgICAgICAgICAgIHRhcmdldF9icmFja2V0PXRhcmdldF9icmFja2V0LAogICAgICAgICAgICBidWRnZXRfdXNkPWJ1ZGdldF91c2QsCiAgICAgICAgICAgIGJ1ZGdldF9tb2RlPWJ1ZGdldF9tb2RlLAogICAgICAgICAgICBvbWl0X2NhcmRzPW9taXRfY2FyZHMsCiAgICAgICAgICAgIGNvbGxlY3Rpb25fc3VtbWFyeT1jb2xsZWN0aW9uX2Jsb2NrLAogICAgICAgICkKCiAgICAgICAgIyBTdGVwIDQ6IENhbGwgUGVycGxleGl0eSB3aXRoIHN0cnVjdHVyZWQgb3V0cHV0CiAgICAgICAgdXNlX21vZGVsID0gbW9kZWwgb3IgREVDS19HRU5fTU9ERUwKICAgICAgICBsb2dnZXIuaW5mbygiQ2FsbGluZyBQZXJwbGV4aXR5ICglcykgZm9y' +
'IGRlY2sgZ2VuZXJhdGlvbi4uLiIsIHVzZV9tb2RlbCkKCiAgICAgICAgcmVzcG9uc2UgPSBzZWxmLnBwbHguY2hhdF9zdHJ1Y3R1cmVkKAogICAgICAgICAgICBzeXN0ZW1fcHJvbXB0PVNZU1RFTV9QUk9NUFQsCiAgICAgICAgICAgIHVzZXJfcHJvbXB0PXVzZXJfcHJvbXB0LAogICAgICAgICAgICBqc29uX3NjaGVtYT1ERUNLX0xJU1RfU0NIRU1BLAogICAgICAgICAgICBzY2hlbWFfbmFtZT0iRGVja0xpc3QiLAogICAgICAgICAgICBtb2RlbD11c2VfbW9kZWwsCiAgICAgICAgICAgIHRlbXBlcmF0dXJlPURFQ0tfR0VOX1RFTVBFUkFUVVJFLAogICAgICAgICAgICBtYXhfdG9rZW5zPURFQ0tfR0VOX01BWF9UT0tFTlMsCiAgICAgICAgKQoKICAgICAgICBpZiBub3QgcmVzcG9uc2Uub2s6CiAgICAgICAgICAgIHJhaXNlIFZhbHVlRXJyb3IoCiAgICAgICAgICAgICAgICBmIlBlcnBsZXhpdHkgcmV0dXJuZWQgaW52YWxpZCByZXNwb25zZToge3Jlc3BvbnNlLmNvbnRlbnRbOjMwMF19IgogICAgICAgICAgICApCgogICAgICAgIHJhd19kZWNrID0gcmVzcG9uc2UucGFyc2VkX2pzb24KICAgICAgICBjYXJkX2NvdW50ID0gbGVuKHJhd19kZWNrLmdldCgiY2FyZHMiLCBbXSkpCiAgICAgICAgdHJ1bmNhdGVkID0gY2FyZF9jb3VudCA8IDkwICAjIENvbW1hbmRlciBkZWNrcyBzaG91bGQgaGF2ZSB+MTAwIGNhcmRzCiAgICAgICAgbG9nZ2VyLmluZm8oIkRlY2sgZ2VuZXJhdGVkOiAlZCBjYXJkcywgYnJhY2tldCAlZCVzIiwKICAgICAgICAgICAgICAgICAgICAgY2FyZF9jb3VudCwKICAgICAgICAgICAgICAgICAgICAgcmF3X2RlY2suZ2V0KCJicmFja2V0Iiwge30pLmdldCgibGV2ZWwiLCAwKSwKICAgICAgICAgICAgICAgICAgICAgIiAoVFJVTkNBVEVEIOKAlCByZXNwb25zZSBtYXkgaGF2ZSBoaXQgdG9rZW4gbGltaXQpIiBpZiB0cnVuY2F0ZWQgZWxzZSAiIikKCiAgICAgICAgIyBTdGVwIDU6IENoZWNrIG93bmVyc2hpcCBhbmQgYnVpbGQgZW5yaWNoZWQgY2FyZCBsaXN0CiAgICAgICAgY2FyZHNfd2l0aF9zdGF0dXMgPSBzZWxmLl9jaGVja19vd25lcnNoaXAocmF3X2RlY2suZ2V0KCJjYXJkcyIsIFtdKSkKCiAgICAgICAgIyBTdGVwIDY6IENvbXB1dGUgc3RhdHMKICAgICAgICBzdGF0cyA9IHNlbGYuX2NvbXB1dGVfc3RhdHMoY2FyZHNfd2l0aF9zdGF0dXMpCgogICAgICAgIHJldHVybiB7CiAgICAgICAgICAgICJjb21tYW5kZXIiOiBjbWRyLAogICAgICAgICAgICAiY29sb3JfaWRlbnRpdHkiOiBjb2xvcl9pZGVudGl0eSwKICAgICAgICAgICAgInJhd19kZWNrIjogcmF3X2RlY2ssCiAgICAgICAgICAgICJjYXJkcyI6IFtjLm1vZGVsX2R1bXAoKSBmb3IgYyBpbiBjYXJkc193aXRoX3N0YXR1c10sCiAgICAgICAgICAgICJzdGF0cyI6IHN0YXRzLAogICAgICAgICAgICAic3RyYXRlZ3lfc3VtbWFyeSI6IHJhd19kZWNrLmdldCgic3RyYXRlZ3lfc3VtbWFyeSIsICIiKSwKICAgICAgICAgICAgImJyYWNrZXQiOiByYXdfZGVjay5nZXQoImJyYWNrZXQiLCB7fSksCiAgICAgICAgICAgICJhcmNoZXR5cGUiOiByYXdfZGVjay5nZXQoImFyY2hldHlwZSIsICIiKSwKICAgICAgICAgICAgInJlYXNvbmluZyI6IHJhd19kZWNrLmdldCgicmVhc29uaW5nIiwge30pLAogICAgICAgICAgICAiZXN0aW1hdGVkX3RvdGFsX3VzZCI6IHJhd19kZWNrLmdldCgiZXN0aW1hdGVkX3RvdGFsX3VzZCIsIDApLAogICAgICAgICAgICAidG9rZW5zX3VzZWQiOiB7CiAgICAgICAgICAgICAgICAicHJvbXB0IjogcmVzcG9uc2UucHJvbXB0X3Rva2VucywKICAgICAgICAgICAgICAgICJjb21wbGV0aW9uIjogcmVzcG9uc2UuY29tcGxldGlvbl90b2tlbnMsCiAgICAgICAgICAgIH0sCiAgICAgICAgICAgICJtb2RlbCI6IHJlc3BvbnNlLm1vZGVsLAogICAgICAgICAgICAiY2l0YXRpb25zIjogcmVzcG9uc2UuY2l0YXRpb25zLAogICAgICAgICAgICAidHJ1bmNhdGVkIjogdHJ1bmNhdGVkLAogICAgICAgIH0KCiAgICAjIOKUgOKUgCBTdGVwIDU6IE93bmVyc2hpcCBDaGVjayDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKICAgIGRlZiBfY2hlY2tfb3duZXJzaGlwKHNlbGYsIGNhcmRzOiBMaXN0W2RpY3RdKSAtPiBMaXN0W0RlY2tDYXJkV2l0aFN0YXR1c106CiAgICAgICAgIiIiCiAgICAgICAgQ3Jvc3MtcmVmZXJlbmNlIGdlbmVyYXRlZCBjYXJkcyBh' +
'Z2FpbnN0IHRoZSBjb2xsZWN0aW9uIERCLgogICAgICAgIFNldHMgb3duZWQvc2NyeWZhbGxfaWQvb3duZWRfcXR5IGFuZCByZWFsIHByaWNlLgogICAgICAgICIiIgogICAgICAgIGNvbm4gPSBzZWxmLmRiX2Nvbm5fZmFjdG9yeSgpCiAgICAgICAgZW5yaWNoZWQgPSBbXQoKICAgICAgICBmb3IgY2FyZCBpbiBjYXJkczoKICAgICAgICAgICAgbmFtZSA9IGNhcmQuZ2V0KCJuYW1lIiwgIiIpCiAgICAgICAgICAgIGlmIG5vdCBuYW1lOgogICAgICAgICAgICAgICAgY29udGludWUKCiAgICAgICAgICAgICMgTG9vayB1cCBpbiBjb2xsZWN0aW9uCiAgICAgICAgICAgIHJvdyA9IGNvbm4uZXhlY3V0ZSgKICAgICAgICAgICAgICAgICJTRUxFQ1QgbmFtZSwgc2NyeWZhbGxfaWQsIHF1YW50aXR5LCB0Y2dfcHJpY2UsIHR5cGVfbGluZSwgY21jICIKICAgICAgICAgICAgICAgICJGUk9NIGNvbGxlY3Rpb25fZW50cmllcyBXSEVSRSBuYW1lID0gPyBDT0xMQVRFIE5PQ0FTRSBMSU1JVCAxIiwKICAgICAgICAgICAgICAgIChuYW1lLCkKICAgICAgICAgICAgKS5mZXRjaG9uZSgpCgogICAgICAgICAgICBpc19vd25lZCA9IGJvb2wocm93IGFuZCByb3dbInF1YW50aXR5Il0gYW5kIHJvd1sicXVhbnRpdHkiXSA+IDApCgogICAgICAgICAgICBlbnJpY2hlZF9jYXJkID0gRGVja0NhcmRXaXRoU3RhdHVzKAogICAgICAgICAgICAgICAgbmFtZT1yb3dbIm5hbWUiXSBpZiByb3cgZWxzZSBuYW1lLAogICAgICAgICAgICAgICAgY291bnQ9Y2FyZC5nZXQoImNvdW50IiwgMSksCiAgICAgICAgICAgICAgICBjYXRlZ29yeT1jYXJkLmdldCgiY2F0ZWdvcnkiLCAiIiksCiAgICAgICAgICAgICAgICByb2xlX3RhZ3M9Y2FyZC5nZXQoInJvbGVfdGFncyIsIFtdKSwKICAgICAgICAgICAgICAgIHJlYXNvbj1jYXJkLmdldCgicmVhc29uIiwgIiIpLAogICAgICAgICAgICAgICAgZXN0aW1hdGVkX3ByaWNlX3VzZD0oCiAgICAgICAgICAgICAgICAgICAgcm91bmQocm93WyJ0Y2dfcHJpY2UiXSwgMikgaWYgcm93IGFuZCByb3dbInRjZ19wcmljZSJdCiAgICAgICAgICAgICAgICAgICAgZWxzZSBjYXJkLmdldCgiZXN0aW1hdGVkX3ByaWNlX3VzZCIsIDApCiAgICAgICAgICAgICAgICApLAogICAgICAgICAgICAgICAgc3luZXJneV93aXRoPWNhcmQuZ2V0KCJzeW5lcmd5X3dpdGgiLCBbXSksCiAgICAgICAgICAgICAgICBvd25lZD1pc19vd25lZCwKICAgICAgICAgICAgICAgIG93bmVkX3F0eT1pbnQocm93WyJxdWFudGl0eSJdKSBpZiByb3cgYW5kIHJvd1sicXVhbnRpdHkiXSBlbHNlIDAsCiAgICAgICAgICAgICAgICBzY3J5ZmFsbF9pZD1yb3dbInNjcnlmYWxsX2lkIl0gaWYgcm93IGVsc2UgIiIsCiAgICAgICAgICAgICAgICBzdGF0dXM9Im93bmVkIiBpZiBpc19vd25lZCBlbHNlICJtaXNzaW5nIiwKICAgICAgICAgICAgKQogICAgICAgICAgICBlbnJpY2hlZC5hcHBlbmQoZW5yaWNoZWRfY2FyZCkKCiAgICAgICAgcmV0dXJuIGVucmljaGVkCgogICAgIyDilIDilIAgU3RlcCA2OiBTbWFydCBTdWJzdGl0dXRpb24g4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACiAgICBkZWYgcnVuX3N1YnN0aXR1dGlvbigKICAgICAgICBzZWxmLAogICAgICAgIGNhcmRzOiBMaXN0W0RlY2tDYXJkV2l0aFN0YXR1c10sCiAgICAgICAgY29tbWFuZGVyOiBkaWN0LAogICAgICAgIHN0cmF0ZWd5OiBzdHIgPSAiIiwKICAgICkgLT4gU3Vic3RpdHV0aW9uQmF0Y2hSZXN1bHQ6CiAgICAgICAgIiIiCiAgICAgICAgUnVuIHRoZSBTbWFydCBTdWJzdGl0dXRpb24gRW5naW5lIG9uIG1pc3NpbmcgY2FyZHMuCgogICAgICAgIFBpcGVsaW5lOgogICAgICAgICAgYSkgSWRlbnRpZnkgbWlzc2luZyBjYXJkcwogICAgICAgICAgYikgRm9yIGVhY2g6IHRyeSBlbWJlZGRpbmcgc2ltaWxhcml0eSAoZnJlZSwgZmFzdCkKICAgICAgICAgIGMpIEZvciBsb3ctY29uZmlkZW5jZSBtYXRjaGVzOiBiYXRjaCBQZXJwbGV4aXR5IGZhbGxiYWNrCiAgICAgICAgICBkKSBBdXRvLXNlbGVjdCBiZXN0IHN1YnN0aXR1dGUgcGVyIGNhcmQKCiAgICAgICAgUmV0dXJucyBTdWJzdGl0dXRpb25CYXRjaFJlc3VsdCB3aXRoIGFsbCBjYXJkcyB1cGRhdGVkLgogICAgICAgICIiIgogICAgICAgIGNvbG9yX2lkZW50aXR5ID0gY29tbWFuZGVyLmdldCgiY29sb3JfaWRlbnRpdHkiLCBbXSkKICAgICAgICBtaXNzaW5nID0gW2Mg' +
'Zm9yIGMgaW4gY2FyZHMgaWYgbm90IGMub3duZWRdCiAgICAgICAgb3duZWQgPSBbYyBmb3IgYyBpbiBjYXJkcyBpZiBjLm93bmVkXQoKICAgICAgICBpZiBub3QgbWlzc2luZzoKICAgICAgICAgICAgcmV0dXJuIFN1YnN0aXR1dGlvbkJhdGNoUmVzdWx0KAogICAgICAgICAgICAgICAgdG90YWxfY2FyZHM9bGVuKGNhcmRzKSwKICAgICAgICAgICAgICAgIG93bmVkX2NvdW50PWxlbihvd25lZCksCiAgICAgICAgICAgICAgICBzdWJzdGl0dXRlZF9jb3VudD0wLAogICAgICAgICAgICAgICAgbWlzc2luZ19jb3VudD0wLAogICAgICAgICAgICAgICAgY2FyZHM9Y2FyZHMsCiAgICAgICAgICAgICkKCiAgICAgICAgbG9nZ2VyLmluZm8oIlN1YnN0aXR1dGlvbjogJWQgbWlzc2luZyAvICVkIG93bmVkIiwgbGVuKG1pc3NpbmcpLCBsZW4ob3duZWQpKQoKICAgICAgICAjIEJ1aWxkIGxpc3Qgb2Ygb3duZWQgY2FyZCBuYW1lcyBmb3Igc3Vic3RpdHV0aW9uIHBvb2wKICAgICAgICBjb25uID0gc2VsZi5kYl9jb25uX2ZhY3RvcnkoKQogICAgICAgIG93bmVkX3Bvb2xfcm93cyA9IGNvbm4uZXhlY3V0ZSgKICAgICAgICAgICAgIlNFTEVDVCBESVNUSU5DVCBuYW1lIEZST00gY29sbGVjdGlvbl9lbnRyaWVzIFdIRVJFIHF1YW50aXR5ID4gMCIKICAgICAgICApLmZldGNoYWxsKCkKICAgICAgICBvd25lZF9wb29sID0gW3JbIm5hbWUiXSBmb3IgciBpbiBvd25lZF9wb29sX3Jvd3NdCgogICAgICAgICMgRXhjbHVkZSBjYXJkcyBhbHJlYWR5IGluIHRoZSBkZWNrCiAgICAgICAgZGVja19uYW1lcyA9IHNldChjLm5hbWUubG93ZXIoKSBmb3IgYyBpbiBjYXJkcykKICAgICAgICBhdmFpbGFibGVfcG9vbCA9IFtuIGZvciBuIGluIG93bmVkX3Bvb2wgaWYgbi5sb3dlcigpIG5vdCBpbiBkZWNrX25hbWVzXQoKICAgICAgICAjIGEpIEVtYmVkZGluZyBwYXNzCiAgICAgICAgbmVlZHNfcHBseF9mYWxsYmFjayA9IFtdCiAgICAgICAgZm9yIGNhcmQgaW4gbWlzc2luZzoKICAgICAgICAgICAgZW1iZWRkaW5nX2FsdHMgPSBzZWxmLl9lbWJlZGRpbmdfc3Vic3RpdHV0aW9uKAogICAgICAgICAgICAgICAgY2FyZCwgY29sb3JfaWRlbnRpdHksIGF2YWlsYWJsZV9wb29sCiAgICAgICAgICAgICkKICAgICAgICAgICAgY2FyZC5hbHRlcm5hdGl2ZXMgPSBlbWJlZGRpbmdfYWx0cwoKICAgICAgICAgICAgIyBDaGVjayBpZiBiZXN0IG1hdGNoIG1lZXRzIHRocmVzaG9sZAogICAgICAgICAgICBpZiBlbWJlZGRpbmdfYWx0cyBhbmQgZW1iZWRkaW5nX2FsdHNbMF0uc2ltaWxhcml0eV9zY29yZSA+PSBTVUJTVElUVVRJT05fTUlOX1NJTUlMQVJJVFk6CiAgICAgICAgICAgICAgICBjYXJkLnNlbGVjdGVkX3N1YnN0aXR1dGUgPSBlbWJlZGRpbmdfYWx0c1swXS5uYW1lCiAgICAgICAgICAgICAgICBjYXJkLnN0YXR1cyA9ICJzdWJzdGl0dXRlZCIKICAgICAgICAgICAgICAgICMgUmVtb3ZlIHNlbGVjdGVkIGZyb20gcG9vbAogICAgICAgICAgICAgICAgaWYgY2FyZC5zZWxlY3RlZF9zdWJzdGl0dXRlLmxvd2VyKCkgaW4gW24ubG93ZXIoKSBmb3IgbiBpbiBhdmFpbGFibGVfcG9vbF06CiAgICAgICAgICAgICAgICAgICAgYXZhaWxhYmxlX3Bvb2wgPSBbbiBmb3IgbiBpbiBhdmFpbGFibGVfcG9vbAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgaWYgbi5sb3dlcigpICE9IGNhcmQuc2VsZWN0ZWRfc3Vic3RpdHV0ZS5sb3dlcigpXQogICAgICAgICAgICBlbHNlOgogICAgICAgICAgICAgICAgbmVlZHNfcHBseF9mYWxsYmFjay5hcHBlbmQoY2FyZCkKCiAgICAgICAgIyBiKSBQZXJwbGV4aXR5IGZhbGxiYWNrIGZvciBsb3ctY29uZmlkZW5jZSBtYXRjaGVzCiAgICAgICAgaWYgbmVlZHNfcHBseF9mYWxsYmFjayBhbmQgU1VCU1RJVFVUSU9OX1VTRV9QUExYX0ZBTExCQUNLOgogICAgICAgICAgICBzZWxmLl9wcGx4X3N1YnN0aXR1dGlvbigKICAgICAgICAgICAgICAgIG5lZWRzX3BwbHhfZmFsbGJhY2ssIGF2YWlsYWJsZV9wb29sLAogICAgICAgICAgICAgICAgY29tbWFuZGVyLCBzdHJhdGVneQogICAgICAgICAgICApCgogICAgICAgICMgQ291bnQgcmVzdWx0cwogICAgICAgIHN1YnN0aXR1dGVkID0gc3VtKDEgZm9yIGMgaW4gY2FyZHMgaWYgYy5zdGF0dXMgPT0gInN1YnN0aXR1dGVkIikKICAgICAgICBzdGlsbF9taXNzaW5nID0gc3VtKDEgZm9yIGMgaW4gY2FyZHMgaWYgYy5zdGF0dXMgPT0gIm1pc3NpbmciKQoKICAgICAgICByZXR1cm4gU3Vic3RpdHV0aW9u' +
'QmF0Y2hSZXN1bHQoCiAgICAgICAgICAgIHRvdGFsX2NhcmRzPWxlbihjYXJkcyksCiAgICAgICAgICAgIG93bmVkX2NvdW50PWxlbihvd25lZCksCiAgICAgICAgICAgIHN1YnN0aXR1dGVkX2NvdW50PXN1YnN0aXR1dGVkLAogICAgICAgICAgICBtaXNzaW5nX2NvdW50PXN0aWxsX21pc3NpbmcsCiAgICAgICAgICAgIGNhcmRzPWNhcmRzLAogICAgICAgICkKCiAgICBkZWYgX2VtYmVkZGluZ19zdWJzdGl0dXRpb24oCiAgICAgICAgc2VsZiwKICAgICAgICBjYXJkOiBEZWNrQ2FyZFdpdGhTdGF0dXMsCiAgICAgICAgY29sb3JfaWRlbnRpdHk6IExpc3Rbc3RyXSwKICAgICAgICBhdmFpbGFibGVfcG9vbDogTGlzdFtzdHJdLAogICAgKSAtPiBMaXN0W0NhcmRBbHRlcm5hdGl2ZV06CiAgICAgICAgIiIiRmluZCBlbWJlZGRpbmctc2ltaWxhciBvd25lZCBjYXJkcyBhcyBzdWJzdGl0dXRlcy4iIiIKICAgICAgICBpZiBub3Qgc2VsZi5lbWJlZGRpbmdfaW5kZXggb3Igbm90IHNlbGYuZW1iZWRkaW5nX2luZGV4LmxvYWRlZDoKICAgICAgICAgICAgcmV0dXJuIFtdCgogICAgICAgICMgVXNlIHRoZSBlbWJlZGRpbmdzIHNlYXJjaCB0byBmaW5kIHNpbWlsYXIgY2FyZHMKICAgICAgICBtYXRjaGVzID0gc2VsZi5lbWJlZGRpbmdfaW5kZXguc2VhcmNoX3NpbWlsYXIoCiAgICAgICAgICAgIHF1ZXJ5X2NhcmQ9Y2FyZC5uYW1lLAogICAgICAgICAgICBjb2xvcl9maWx0ZXI9Y29sb3JfaWRlbnRpdHkgaWYgY29sb3JfaWRlbnRpdHkgZWxzZSBOb25lLAogICAgICAgICAgICB0eXBlX2ZpbHRlcj1jYXJkLmNhdGVnb3J5IGlmIGNhcmQuY2F0ZWdvcnkgZWxzZSBOb25lLAogICAgICAgICAgICBleGNsdWRlX2NhcmRzPVtjYXJkLm5hbWVdLAogICAgICAgICAgICB0b3Bfbj1TVUJTVElUVVRJT05fTUFYX0FMVEVSTkFUSVZFUyAqIDMsICAjIE92ZXItZmV0Y2gsIHRoZW4gZmlsdGVyIHRvIG93bmVkCiAgICAgICAgKQoKICAgICAgICAjIEZpbHRlciB0byBvbmx5IGNhcmRzIGluIHRoZSBhdmFpbGFibGUgcG9vbAogICAgICAgIHBvb2xfbG93ZXIgPSBzZXQobi5sb3dlcigpIGZvciBuIGluIGF2YWlsYWJsZV9wb29sKQogICAgICAgIGFsdGVybmF0aXZlcyA9IFtdCiAgICAgICAgZm9yIG1hdGNoIGluIG1hdGNoZXM6CiAgICAgICAgICAgIGlmIG1hdGNoLm5hbWUubG93ZXIoKSBpbiBwb29sX2xvd2VyOgogICAgICAgICAgICAgICAgIyBDb21wdXRlIHJvbGUgb3ZlcmxhcAogICAgICAgICAgICAgICAgbWF0Y2hfcm9sZXMgPSBbXSAgIyBXb3VsZCBuZWVkIGNhcmQncyByb2xlX3RhZ3MgZnJvbSBEQgogICAgICAgICAgICAgICAgcm9sZV9vdmVybGFwID0gbGlzdChzZXQoY2FyZC5yb2xlX3RhZ3MpICYgc2V0KG1hdGNoX3JvbGVzKSkgaWYgbWF0Y2hfcm9sZXMgZWxzZSBbXQoKICAgICAgICAgICAgICAgIGFsdGVybmF0aXZlcy5hcHBlbmQoQ2FyZEFsdGVybmF0aXZlKAogICAgICAgICAgICAgICAgICAgIG5hbWU9bWF0Y2gubmFtZSwKICAgICAgICAgICAgICAgICAgICBzaW1pbGFyaXR5X3Njb3JlPW1hdGNoLnNpbWlsYXJpdHksCiAgICAgICAgICAgICAgICAgICAgcmVhc29uPWYiU2ltaWxhciB0byB7Y2FyZC5uYW1lfSAoY29zaW5lIHNpbToge21hdGNoLnNpbWlsYXJpdHk6LjNmfSkiLAogICAgICAgICAgICAgICAgICAgIHNvdXJjZT0iZW1iZWRkaW5nIiwKICAgICAgICAgICAgICAgICAgICByb2xlX292ZXJsYXA9cm9sZV9vdmVybGFwLAogICAgICAgICAgICAgICAgICAgIGNtY19kZWx0YT1tYXRjaC5tYW5hX3ZhbHVlIC0gKGNhcmQuZXN0aW1hdGVkX3ByaWNlX3VzZCBvciAwKSwKICAgICAgICAgICAgICAgICkpCgogICAgICAgICAgICAgICAgaWYgbGVuKGFsdGVybmF0aXZlcykgPj0gU1VCU1RJVFVUSU9OX01BWF9BTFRFUk5BVElWRVM6CiAgICAgICAgICAgICAgICAgICAgYnJlYWsKCiAgICAgICAgcmV0dXJuIGFsdGVybmF0aXZlcwoKICAgIGRlZiBfcHBseF9zdWJzdGl0dXRpb24oCiAgICAgICAgc2VsZiwKICAgICAgICBtaXNzaW5nX2NhcmRzOiBMaXN0W0RlY2tDYXJkV2l0aFN0YXR1c10sCiAgICAgICAgYXZhaWxhYmxlX3Bvb2w6IExpc3Rbc3RyXSwKICAgICAgICBjb21tYW5kZXI6IGRpY3QsCiAgICAgICAgc3RyYXRlZ3k6IHN0ciwKICAgICk6CiAgICAgICAgIiIiCiAgICAgICAgQmF0Y2ggUGVycGxleGl0eSBmYWxsYmFjayBmb3IgY2FyZHMgd2l0aCBsb3cgZW1iZWRkaW5nIGNvbmZpZGVuY2UuCiAgICAgICAgTXV0YXRlcyB0aGUgY2FyZHMgaW4tcGxh' +
'Y2Ugd2l0aCBzdWJzdGl0dXRpb24gZGF0YS4KICAgICAgICAiIiIKICAgICAgICBpZiBub3QgYXZhaWxhYmxlX3Bvb2w6CiAgICAgICAgICAgIHJldHVybgoKICAgICAgICAjIEJ1aWxkIHRoZSByZXF1ZXN0CiAgICAgICAgbWlzc2luZ19kaWN0cyA9IFsKICAgICAgICAgICAgewogICAgICAgICAgICAgICAgIm5hbWUiOiBjLm5hbWUsCiAgICAgICAgICAgICAgICAicm9sZV90YWdzIjogYy5yb2xlX3RhZ3MsCiAgICAgICAgICAgICAgICAiY2F0ZWdvcnkiOiBjLmNhdGVnb3J5LAogICAgICAgICAgICAgICAgInJlYXNvbiI6IGMucmVhc29uLAogICAgICAgICAgICB9CiAgICAgICAgICAgIGZvciBjIGluIG1pc3NpbmdfY2FyZHMKICAgICAgICBdCgogICAgICAgIHVzZXJfcHJvbXB0ID0gYnVpbGRfc3Vic3RpdHV0aW9uX3Byb21wdCgKICAgICAgICAgICAgbWlzc2luZ19jYXJkcz1taXNzaW5nX2RpY3RzLAogICAgICAgICAgICBhbGxvd2VkX2NhcmRzPWF2YWlsYWJsZV9wb29sWzoyMDBdLCAgIyBMaW1pdCB0byBhdm9pZCB0b2tlbiBvdmVyZmxvdwogICAgICAgICAgICBjb21tYW5kZXI9Y29tbWFuZGVyLmdldCgibmFtZSIsICIiKSwKICAgICAgICAgICAgY29sb3JfaWRlbnRpdHk9Y29tbWFuZGVyLmdldCgiY29sb3JfaWRlbnRpdHkiLCBbXSksCiAgICAgICAgICAgIHN0cmF0ZWd5PXN0cmF0ZWd5LAogICAgICAgICkKCiAgICAgICAgdHJ5OgogICAgICAgICAgICBmcm9tIC4uc2NoZW1hcy5zdWJzdGl0dXRpb25fc2NoZW1hIGltcG9ydCBTVUJTVElUVVRJT05fUkVTUE9OU0VfU0NIRU1BCiAgICAgICAgICAgIHJlc3BvbnNlID0gc2VsZi5wcGx4LmNoYXRfc3RydWN0dXJlZCgKICAgICAgICAgICAgICAgIHN5c3RlbV9wcm9tcHQ9U1VCU1RJVFVUSU9OX1NZU1RFTSwKICAgICAgICAgICAgICAgIHVzZXJfcHJvbXB0PXVzZXJfcHJvbXB0LAogICAgICAgICAgICAgICAganNvbl9zY2hlbWE9U1VCU1RJVFVUSU9OX1JFU1BPTlNFX1NDSEVNQSwKICAgICAgICAgICAgICAgIHNjaGVtYV9uYW1lPSJTdWJzdGl0dXRpb25SZXNwb25zZSIsCiAgICAgICAgICAgICAgICBtb2RlbD1TVUJTVElUVVRJT05fTU9ERUwsCiAgICAgICAgICAgICAgICB0ZW1wZXJhdHVyZT0wLjEsCiAgICAgICAgICAgICAgICBtYXhfdG9rZW5zPTQwOTYsCiAgICAgICAgICAgICkKCiAgICAgICAgICAgIGlmIG5vdCByZXNwb25zZS5vazoKICAgICAgICAgICAgICAgIGxvZ2dlci53YXJuaW5nKCJQZXJwbGV4aXR5IHN1YnN0aXR1dGlvbiBmYWxsYmFjayBmYWlsZWQg4oCUIG5vbi1KU09OIHJlc3BvbnNlIikKICAgICAgICAgICAgICAgIHJldHVybgoKICAgICAgICAgICAgc3VicyA9IHJlc3BvbnNlLnBhcnNlZF9qc29uLmdldCgic3Vic3RpdHV0aW9ucyIsIFtdKQogICAgICAgICAgICBsb2dnZXIuaW5mbygiUGVycGxleGl0eSBzdWdnZXN0ZWQgJWQgc3Vic3RpdHV0aW9ucyIsIGxlbihzdWJzKSkKCiAgICAgICAgICAgICMgQXBwbHkgc3Vic3RpdHV0aW9ucwogICAgICAgICAgICBjYXJkX2J5X25hbWUgPSB7Yy5uYW1lLmxvd2VyKCk6IGMgZm9yIGMgaW4gbWlzc2luZ19jYXJkc30KICAgICAgICAgICAgZm9yIHN1YiBpbiBzdWJzOgogICAgICAgICAgICAgICAgb3JpZyA9IHN1Yi5nZXQoIm9yaWdpbmFsIiwgIiIpLmxvd2VyKCkKICAgICAgICAgICAgICAgIHN1YnN0aXR1dGUgPSBzdWIuZ2V0KCJzdWJzdGl0dXRlIiwgIiIpCiAgICAgICAgICAgICAgICByZWFzb24gPSBzdWIuZ2V0KCJyZWFzb24iLCAiIikKCiAgICAgICAgICAgICAgICBpZiBvcmlnIGluIGNhcmRfYnlfbmFtZSBhbmQgc3Vic3RpdHV0ZToKICAgICAgICAgICAgICAgICAgICBjYXJkID0gY2FyZF9ieV9uYW1lW29yaWddCiAgICAgICAgICAgICAgICAgICAgIyBBZGQgYXMgdG9wIGFsdGVybmF0aXZlCiAgICAgICAgICAgICAgICAgICAgcHBseF9hbHQgPSBDYXJkQWx0ZXJuYXRpdmUoCiAgICAgICAgICAgICAgICAgICAgICAgIG5hbWU9c3Vic3RpdHV0ZSwKICAgICAgICAgICAgICAgICAgICAgICAgc2ltaWxhcml0eV9zY29yZT0wLjgwLCAgIyBOb21pbmFsIHNjb3JlIGZvciBQZXJwbGV4aXR5IHN1Z2dlc3Rpb25zCiAgICAgICAgICAgICAgICAgICAgICAgIHJlYXNvbj1yZWFzb24sCiAgICAgICAgICAgICAgICAgICAgICAgIHNvdXJjZT0icGVycGxleGl0eSIsCiAgICAgICAgICAgICAgICAgICAgICAgIHJvbGVfb3ZlcmxhcD1zdWIuZ2V0KCJyb2xlX292ZXJsYXAiLCBbXSksCiAgICAgICAgICAgICAgICAgICAgKQog' +
'ICAgICAgICAgICAgICAgICAgICMgSW5zZXJ0IGF0IGZyb250CiAgICAgICAgICAgICAgICAgICAgY2FyZC5hbHRlcm5hdGl2ZXMuaW5zZXJ0KDAsIHBwbHhfYWx0KQogICAgICAgICAgICAgICAgICAgIGNhcmQuc2VsZWN0ZWRfc3Vic3RpdHV0ZSA9IHN1YnN0aXR1dGUKICAgICAgICAgICAgICAgICAgICBjYXJkLnN0YXR1cyA9ICJzdWJzdGl0dXRlZCIKCiAgICAgICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgICAgICBsb2dnZXIuZXJyb3IoIlBlcnBsZXhpdHkgc3Vic3RpdHV0aW9uIGZhbGxiYWNrIGVycm9yOiAlcyIsIGUpCgogICAgIyDilIDilIAgU3RhdHMg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACiAgICBAc3RhdGljbWV0aG9kCiAgICBkZWYgX2NvbXB1dGVfc3RhdHMoY2FyZHM6IExpc3RbRGVja0NhcmRXaXRoU3RhdHVzXSkgLT4gZGljdDoKICAgICAgICAiIiJDb21wdXRlIGRlY2sgc3RhdGlzdGljcyBmcm9tIHRoZSBlbnJpY2hlZCBjYXJkIGxpc3QuIiIiCiAgICAgICAgYnlfY2F0ZWdvcnkgPSB7fQogICAgICAgIGJ5X3N0YXR1cyA9IHsib3duZWQiOiAwLCAic3Vic3RpdHV0ZWQiOiAwLCAibWlzc2luZyI6IDB9CiAgICAgICAgdG90YWxfcHJpY2UgPSAwLjAKICAgICAgICBvd25lZF9wcmljZSA9IDAuMAogICAgICAgIG1pc3NpbmdfcHJpY2UgPSAwLjAKICAgICAgICByb2xlX2NvdW50cyA9IHt9CgogICAgICAgIGZvciBjYXJkIGluIGNhcmRzOgogICAgICAgICAgICBjYXQgPSBjYXJkLmNhdGVnb3J5IG9yICJPdGhlciIKICAgICAgICAgICAgYnlfY2F0ZWdvcnlbY2F0XSA9IGJ5X2NhdGVnb3J5LmdldChjYXQsIDApICsgY2FyZC5jb3VudAogICAgICAgICAgICBieV9zdGF0dXNbY2FyZC5zdGF0dXNdID0gYnlfc3RhdHVzLmdldChjYXJkLnN0YXR1cywgMCkgKyAxCiAgICAgICAgICAgIHByaWNlID0gY2FyZC5lc3RpbWF0ZWRfcHJpY2VfdXNkICogY2FyZC5jb3VudAogICAgICAgICAgICB0b3RhbF9wcmljZSArPSBwcmljZQogICAgICAgICAgICBpZiBjYXJkLm93bmVkOgogICAgICAgICAgICAgICAgb3duZWRfcHJpY2UgKz0gcHJpY2UKICAgICAgICAgICAgZWxzZToKICAgICAgICAgICAgICAgIG1pc3NpbmdfcHJpY2UgKz0gcHJpY2UKICAgICAgICAgICAgZm9yIHJvbGUgaW4gY2FyZC5yb2xlX3RhZ3M6CiAgICAgICAgICAgICAgICByb2xlX2NvdW50c1tyb2xlXSA9IHJvbGVfY291bnRzLmdldChyb2xlLCAwKSArIDEKCiAgICAgICAgbGFuZF9jb3VudCA9IGJ5X2NhdGVnb3J5LmdldCgiTGFuZCIsIDApCiAgICAgICAgbm9ubGFuZF9jb3VudCA9IHN1bSh2IGZvciBrLCB2IGluIGJ5X2NhdGVnb3J5Lml0ZW1zKCkgaWYgayAhPSAiTGFuZCIpCgogICAgICAgIHJldHVybiB7CiAgICAgICAgICAgICJ0b3RhbF9jYXJkcyI6IHN1bShjLmNvdW50IGZvciBjIGluIGNhcmRzKSwKICAgICAgICAgICAgImxhbmRfY291bnQiOiBsYW5kX2NvdW50LAogICAgICAgICAgICAibm9ubGFuZF9jb3VudCI6IG5vbmxhbmRfY291bnQsCiAgICAgICAgICAgICJieV9jYXRlZ29yeSI6IGJ5X2NhdGVnb3J5LAogICAgICAgICAgICAiYnlfc3RhdHVzIjogYnlfc3RhdHVzLAogICAgICAgICAgICAicm9sZV9jb3VudHMiOiByb2xlX2NvdW50cywKICAgICAgICAgICAgInRvdGFsX3ByaWNlX3VzZCI6IHJvdW5kKHRvdGFsX3ByaWNlLCAyKSwKICAgICAgICAgICAgIm93bmVkX3ByaWNlX3VzZCI6IHJvdW5kKG93bmVkX3ByaWNlLCAyKSwKICAgICAgICAgICAgIm1pc3NpbmdfcHJpY2VfdXNkIjogcm91bmQobWlzc2luZ19wcmljZSwgMiksCiAgICAgICAgfQo='
'IGRlY2sgZ2VuZXJhdGlvbi4uLiIsIHVzZV9tb2RlbCkKCiAgICAgICAgcmVzcG9uc2UgPSBzZWxmLnBwbHguY2hhdF9zdHJ1Y3R1cmVkKAogICAgICAgICAgICBzeXN0ZW1fcHJvbXB0PVNZU1RFTV9QUk9NUFQsCiAgICAgICAgICAgIHVzZXJfcHJvbXB0PXVzZXJfcHJvbXB0LAogICAgICAgICAgICBqc29uX3NjaGVtYT1ERUNLX0xJU1RfU0NIRU1BLAogICAgICAgICAgICBzY2hlbWFfbmFtZT0iRGVja0xpc3QiLAogICAgICAgICAgICBtb2RlbD11c2VfbW9kZWwsCiAgICAgICAgICAgIHRlbXBlcmF0dXJlPURFQ0tfR0VOX1RFTVBFUkFUVVJFLAogICAgICAgICAgICBtYXhfdG9rZW5zPURFQ0tfR0VOX01BWF9UT0tFTlMsCiAgICAgICAgKQoKICAgICAgICBpZiBub3QgcmVzcG9uc2Uub2s6CiAgICAgICAgICAgIHJhaXNlIFZhbHVlRXJyb3IoCiAgICAgICAgICAgICAgICBmIlBlcnBsZXhpdHkgcmV0dXJuZWQgaW52YWxpZCByZXNwb25zZToge3Jlc3BvbnNlLmNvbnRlbnRbOjMwMF19IgogICAgICAgICAgICApCgogICAgICAgIHJhd19kZWNrID0gcmVzcG9uc2UucGFyc2VkX2pzb24KICAgICAgICBsb2dnZXIuaW5mbygiRGVjayBnZW5lcmF0ZWQ6ICVkIGNhcmRzLCBicmFja2V0ICVkIiwKICAgICAgICAgICAgICAgICAgICAgbGVuKHJhd19kZWNrLmdldCgiY2FyZHMiLCBbXSkpLAogICAgICAgICAgICAgICAgICAgICByYXdfZGVjay5nZXQoImJyYWNrZXQiLCB7fSkuZ2V0KCJsZXZlbCIsIDApKQoKICAgICAgICAjIFN0ZXAgNTogQ2hlY2sgb3duZXJzaGlwIGFuZCBidWlsZCBlbnJpY2hlZCBjYXJkIGxpc3QKICAgICAgICBjYXJkc193aXRoX3N0YXR1cyA9IHNlbGYuX2NoZWNrX293bmVyc2hpcChyYXdfZGVjay5nZXQoImNhcmRzIiwgW10pKQoKICAgICAgICAjIFN0ZXAgNjogQ29tcHV0ZSBzdGF0cwogICAgICAgIHN0YXRzID0gc2VsZi5fY29tcHV0ZV9zdGF0cyhjYXJkc193aXRoX3N0YXR1cykKCiAgICAgICAgcmV0dXJuIHsKICAgICAgICAgICAgImNvbW1hbmRlciI6IGNtZHIsCiAgICAgICAgICAgICJjb2xvcl9pZGVudGl0eSI6IGNvbG9yX2lkZW50aXR5LAogICAgICAgICAgICAicmF3X2RlY2siOiByYXdfZGVjaywKICAgICAgICAgICAgImNhcmRzIjogW2MubW9kZWxfZHVtcCgpIGZvciBjIGluIGNhcmRzX3dpdGhfc3RhdHVzXSwKICAgICAgICAgICAgInN0YXRzIjogc3RhdHMsCiAgICAgICAgICAgICJzdHJhdGVneV9zdW1tYXJ5IjogcmF3X2RlY2suZ2V0KCJzdHJhdGVneV9zdW1tYXJ5IiwgIiIpLAogICAgICAgICAgICAiYnJhY2tldCI6IHJhd19kZWNrLmdldCgiYnJhY2tldCIsIHt9KSwKICAgICAgICAgICAgImFyY2hldHlwZSI6IHJhd19kZWNrLmdldCgiYXJjaGV0eXBlIiwgIiIpLAogICAgICAgICAgICAicmVhc29uaW5nIjogcmF3X2RlY2suZ2V0KCJyZWFzb25pbmciLCB7fSksCiAgICAgICAgICAgICJlc3RpbWF0ZWRfdG90YWxfdXNkIjogcmF3X2RlY2suZ2V0KCJlc3RpbWF0ZWRfdG90YWxfdXNkIiwgMCksCiAgICAgICAgICAgICJ0b2tlbnNfdXNlZCI6IHsKICAgICAgICAgICAgICAgICJwcm9tcHQiOiByZXNwb25zZS5wcm9tcHRfdG9rZW5zLAogICAgICAgICAgICAgICAgImNvbXBsZXRpb24iOiByZXNwb25zZS5jb21wbGV0aW9uX3Rva2VucywKICAgICAgICAgICAgfSwKICAgICAgICAgICAgIm1vZGVsIjogcmVzcG9uc2UubW9kZWwsCiAgICAgICAgICAgICJjaXRhdGlvbnMiOiByZXNwb25zZS5jaXRhdGlvbnMsCiAgICAgICAgfQoKICAgICMg4pSA4pSAIFN0ZXAgNTogT3duZXJzaGlwIENoZWNrIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAogICAgZGVmIF9jaGVja19vd25lcnNoaXAoc2VsZiwgY2FyZHM6IExpc3RbZGljdF0pIC0+IExpc3RbRGVja0NhcmRXaXRoU3RhdHVzXToKICAgICAgICAiIiIKICAgICAgICBDcm9zcy1yZWZlcmVuY2UgZ2VuZXJhdGVkIGNhcmRzIGFnYWluc3QgdGhlIGNvbGxlY3Rpb24gREIuCiAgICAgICAgU2V0cyBvd25lZC9zY3J5ZmFsbF9pZC9vd25lZF9xdHkgYW5kIHJlYWwgcHJpY2UuCiAgICAgICAgIiIiCiAgICAgICAgY29ubiA9IHNlbGYuZGJfY29ubl9mYWN0b3J5KCkKICAgICAgICBlbnJpY2hlZCA9IFtdCgogICAgICAgIGZvciBjYXJkIGluIGNhcmRzOgogICAgICAgICAgICBuYW1lID0gY2FyZC5nZXQoIm5hbWUiLCAiIikKICAgICAgICAgICAgaWYgbm90IG5h' +
'bWU6CiAgICAgICAgICAgICAgICBjb250aW51ZQoKICAgICAgICAgICAgIyBMb29rIHVwIGluIGNvbGxlY3Rpb24KICAgICAgICAgICAgcm93ID0gY29ubi5leGVjdXRlKAogICAgICAgICAgICAgICAgIlNFTEVDVCBuYW1lLCBzY3J5ZmFsbF9pZCwgcXVhbnRpdHksIHRjZ19wcmljZSwgdHlwZV9saW5lLCBjbWMgIgogICAgICAgICAgICAgICAgIkZST00gY29sbGVjdGlvbl9lbnRyaWVzIFdIRVJFIG5hbWUgPSA/IENPTExBVEUgTk9DQVNFIExJTUlUIDEiLAogICAgICAgICAgICAgICAgKG5hbWUsKQogICAgICAgICAgICApLmZldGNob25lKCkKCiAgICAgICAgICAgIGlzX293bmVkID0gYm9vbChyb3cgYW5kIHJvd1sicXVhbnRpdHkiXSBhbmQgcm93WyJxdWFudGl0eSJdID4gMCkKCiAgICAgICAgICAgIGVucmljaGVkX2NhcmQgPSBEZWNrQ2FyZFdpdGhTdGF0dXMoCiAgICAgICAgICAgICAgICBuYW1lPXJvd1sibmFtZSJdIGlmIHJvdyBlbHNlIG5hbWUsCiAgICAgICAgICAgICAgICBjb3VudD1jYXJkLmdldCgiY291bnQiLCAxKSwKICAgICAgICAgICAgICAgIGNhdGVnb3J5PWNhcmQuZ2V0KCJjYXRlZ29yeSIsICIiKSwKICAgICAgICAgICAgICAgIHJvbGVfdGFncz1jYXJkLmdldCgicm9sZV90YWdzIiwgW10pLAogICAgICAgICAgICAgICAgcmVhc29uPWNhcmQuZ2V0KCJyZWFzb24iLCAiIiksCiAgICAgICAgICAgICAgICBlc3RpbWF0ZWRfcHJpY2VfdXNkPSgKICAgICAgICAgICAgICAgICAgICByb3VuZChyb3dbInRjZ19wcmljZSJdLCAyKSBpZiByb3cgYW5kIHJvd1sidGNnX3ByaWNlIl0KICAgICAgICAgICAgICAgICAgICBlbHNlIGNhcmQuZ2V0KCJlc3RpbWF0ZWRfcHJpY2VfdXNkIiwgMCkKICAgICAgICAgICAgICAgICksCiAgICAgICAgICAgICAgICBzeW5lcmd5X3dpdGg9Y2FyZC5nZXQoInN5bmVyZ3lfd2l0aCIsIFtdKSwKICAgICAgICAgICAgICAgIG93bmVkPWlzX293bmVkLAogICAgICAgICAgICAgICAgb3duZWRfcXR5PWludChyb3dbInF1YW50aXR5Il0pIGlmIHJvdyBhbmQgcm93WyJxdWFudGl0eSJdIGVsc2UgMCwKICAgICAgICAgICAgICAgIHNjcnlmYWxsX2lkPXJvd1sic2NyeWZhbGxfaWQiXSBpZiByb3cgZWxzZSAiIiwKICAgICAgICAgICAgICAgIHN0YXR1cz0ib3duZWQiIGlmIGlzX293bmVkIGVsc2UgIm1pc3NpbmciLAogICAgICAgICAgICApCiAgICAgICAgICAgIGVucmljaGVkLmFwcGVuZChlbnJpY2hlZF9jYXJkKQoKICAgICAgICByZXR1cm4gZW5yaWNoZWQKCiAgICAjIOKUgOKUgCBTdGVwIDY6IFNtYXJ0IFN1YnN0aXR1dGlvbiDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKICAgIGRlZiBydW5fc3Vic3RpdHV0aW9uKAogICAgICAgIHNlbGYsCiAgICAgICAgY2FyZHM6IExpc3RbRGVja0NhcmRXaXRoU3RhdHVzXSwKICAgICAgICBjb21tYW5kZXI6IGRpY3QsCiAgICAgICAgc3RyYXRlZ3k6IHN0ciA9ICIiLAogICAgKSAtPiBTdWJzdGl0dXRpb25CYXRjaFJlc3VsdDoKICAgICAgICAiIiIKICAgICAgICBSdW4gdGhlIFNtYXJ0IFN1YnN0aXR1dGlvbiBFbmdpbmUgb24gbWlzc2luZyBjYXJkcy4KCiAgICAgICAgUGlwZWxpbmU6CiAgICAgICAgICBhKSBJZGVudGlmeSBtaXNzaW5nIGNhcmRzCiAgICAgICAgICBiKSBGb3IgZWFjaDogdHJ5IGVtYmVkZGluZyBzaW1pbGFyaXR5IChmcmVlLCBmYXN0KQogICAgICAgICAgYykgRm9yIGxvdy1jb25maWRlbmNlIG1hdGNoZXM6IGJhdGNoIFBlcnBsZXhpdHkgZmFsbGJhY2sKICAgICAgICAgIGQpIEF1dG8tc2VsZWN0IGJlc3Qgc3Vic3RpdHV0ZSBwZXIgY2FyZAoKICAgICAgICBSZXR1cm5zIFN1YnN0aXR1dGlvbkJhdGNoUmVzdWx0IHdpdGggYWxsIGNhcmRzIHVwZGF0ZWQuCiAgICAgICAgIiIiCiAgICAgICAgY29sb3JfaWRlbnRpdHkgPSBjb21tYW5kZXIuZ2V0KCJjb2xvcl9pZGVudGl0eSIsIFtdKQogICAgICAgIG1pc3NpbmcgPSBbYyBmb3IgYyBpbiBjYXJkcyBpZiBub3QgYy5vd25lZF0KICAgICAgICBvd25lZCA9IFtjIGZvciBjIGluIGNhcmRzIGlmIGMub3duZWRdCgogICAgICAgIGlmIG5vdCBtaXNzaW5nOgogICAgICAgICAgICByZXR1cm4gU3Vic3RpdHV0aW9uQmF0Y2hSZXN1bHQoCiAgICAgICAgICAgICAgICB0b3RhbF9jYXJkcz1sZW4oY2FyZHMpLAogICAgICAgICAgICAgICAgb3duZWRfY291bnQ9bGVuKG93bmVkKSwKICAgICAgICAgICAgICAgIHN1' +
'YnN0aXR1dGVkX2NvdW50PTAsCiAgICAgICAgICAgICAgICBtaXNzaW5nX2NvdW50PTAsCiAgICAgICAgICAgICAgICBjYXJkcz1jYXJkcywKICAgICAgICAgICAgKQoKICAgICAgICBsb2dnZXIuaW5mbygiU3Vic3RpdHV0aW9uOiAlZCBtaXNzaW5nIC8gJWQgb3duZWQiLCBsZW4obWlzc2luZyksIGxlbihvd25lZCkpCgogICAgICAgICMgQnVpbGQgbGlzdCBvZiBvd25lZCBjYXJkIG5hbWVzIGZvciBzdWJzdGl0dXRpb24gcG9vbAogICAgICAgIGNvbm4gPSBzZWxmLmRiX2Nvbm5fZmFjdG9yeSgpCiAgICAgICAgb3duZWRfcG9vbF9yb3dzID0gY29ubi5leGVjdXRlKAogICAgICAgICAgICAiU0VMRUNUIERJU1RJTkNUIG5hbWUgRlJPTSBjb2xsZWN0aW9uX2VudHJpZXMgV0hFUkUgcXVhbnRpdHkgPiAwIgogICAgICAgICkuZmV0Y2hhbGwoKQogICAgICAgIG93bmVkX3Bvb2wgPSBbclsibmFtZSJdIGZvciByIGluIG93bmVkX3Bvb2xfcm93c10KCiAgICAgICAgIyBFeGNsdWRlIGNhcmRzIGFscmVhZHkgaW4gdGhlIGRlY2sKICAgICAgICBkZWNrX25hbWVzID0gc2V0KGMubmFtZS5sb3dlcigpIGZvciBjIGluIGNhcmRzKQogICAgICAgIGF2YWlsYWJsZV9wb29sID0gW24gZm9yIG4gaW4gb3duZWRfcG9vbCBpZiBuLmxvd2VyKCkgbm90IGluIGRlY2tfbmFtZXNdCgogICAgICAgICMgYSkgRW1iZWRkaW5nIHBhc3MKICAgICAgICBuZWVkc19wcGx4X2ZhbGxiYWNrID0gW10KICAgICAgICBmb3IgY2FyZCBpbiBtaXNzaW5nOgogICAgICAgICAgICBlbWJlZGRpbmdfYWx0cyA9IHNlbGYuX2VtYmVkZGluZ19zdWJzdGl0dXRpb24oCiAgICAgICAgICAgICAgICBjYXJkLCBjb2xvcl9pZGVudGl0eSwgYXZhaWxhYmxlX3Bvb2wKICAgICAgICAgICAgKQogICAgICAgICAgICBjYXJkLmFsdGVybmF0aXZlcyA9IGVtYmVkZGluZ19hbHRzCgogICAgICAgICAgICAjIENoZWNrIGlmIGJlc3QgbWF0Y2ggbWVldHMgdGhyZXNob2xkCiAgICAgICAgICAgIGlmIGVtYmVkZGluZ19hbHRzIGFuZCBlbWJlZGRpbmdfYWx0c1swXS5zaW1pbGFyaXR5X3Njb3JlID49IFNVQlNUSVRVVElPTl9NSU5fU0lNSUxBUklUWToKICAgICAgICAgICAgICAgIGNhcmQuc2VsZWN0ZWRfc3Vic3RpdHV0ZSA9IGVtYmVkZGluZ19hbHRzWzBdLm5hbWUKICAgICAgICAgICAgICAgIGNhcmQuc3RhdHVzID0gInN1YnN0aXR1dGVkIgogICAgICAgICAgICAgICAgIyBSZW1vdmUgc2VsZWN0ZWQgZnJvbSBwb29sCiAgICAgICAgICAgICAgICBpZiBjYXJkLnNlbGVjdGVkX3N1YnN0aXR1dGUubG93ZXIoKSBpbiBbbi5sb3dlcigpIGZvciBuIGluIGF2YWlsYWJsZV9wb29sXToKICAgICAgICAgICAgICAgICAgICBhdmFpbGFibGVfcG9vbCA9IFtuIGZvciBuIGluIGF2YWlsYWJsZV9wb29sCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBpZiBuLmxvd2VyKCkgIT0gY2FyZC5zZWxlY3RlZF9zdWJzdGl0dXRlLmxvd2VyKCldCiAgICAgICAgICAgIGVsc2U6CiAgICAgICAgICAgICAgICBuZWVkc19wcGx4X2ZhbGxiYWNrLmFwcGVuZChjYXJkKQoKICAgICAgICAjIGIpIFBlcnBsZXhpdHkgZmFsbGJhY2sgZm9yIGxvdy1jb25maWRlbmNlIG1hdGNoZXMKICAgICAgICBpZiBuZWVkc19wcGx4X2ZhbGxiYWNrIGFuZCBTVUJTVElUVVRJT05fVVNFX1BQTFhfRkFMTEJBQ0s6CiAgICAgICAgICAgIHNlbGYuX3BwbHhfc3Vic3RpdHV0aW9uKAogICAgICAgICAgICAgICAgbmVlZHNfcHBseF9mYWxsYmFjaywgYXZhaWxhYmxlX3Bvb2wsCiAgICAgICAgICAgICAgICBjb21tYW5kZXIsIHN0cmF0ZWd5CiAgICAgICAgICAgICkKCiAgICAgICAgIyBDb3VudCByZXN1bHRzCiAgICAgICAgc3Vic3RpdHV0ZWQgPSBzdW0oMSBmb3IgYyBpbiBjYXJkcyBpZiBjLnN0YXR1cyA9PSAic3Vic3RpdHV0ZWQiKQogICAgICAgIHN0aWxsX21pc3NpbmcgPSBzdW0oMSBmb3IgYyBpbiBjYXJkcyBpZiBjLnN0YXR1cyA9PSAibWlzc2luZyIpCgogICAgICAgIHJldHVybiBTdWJzdGl0dXRpb25CYXRjaFJlc3VsdCgKICAgICAgICAgICAgdG90YWxfY2FyZHM9bGVuKGNhcmRzKSwKICAgICAgICAgICAgb3duZWRfY291bnQ9bGVuKG93bmVkKSwKICAgICAgICAgICAgc3Vic3RpdHV0ZWRfY291bnQ9c3Vic3RpdHV0ZWQsCiAgICAgICAgICAgIG1pc3NpbmdfY291bnQ9c3RpbGxfbWlzc2luZywKICAgICAgICAgICAgY2FyZHM9Y2FyZHMsCiAgICAgICAgKQoKICAgIGRlZiBfZW1iZWRkaW5nX3N1YnN0aXR1dGlvbigKICAgICAg' +
'ICBzZWxmLAogICAgICAgIGNhcmQ6IERlY2tDYXJkV2l0aFN0YXR1cywKICAgICAgICBjb2xvcl9pZGVudGl0eTogTGlzdFtzdHJdLAogICAgICAgIGF2YWlsYWJsZV9wb29sOiBMaXN0W3N0cl0sCiAgICApIC0+IExpc3RbQ2FyZEFsdGVybmF0aXZlXToKICAgICAgICAiIiJGaW5kIGVtYmVkZGluZy1zaW1pbGFyIG93bmVkIGNhcmRzIGFzIHN1YnN0aXR1dGVzLiIiIgogICAgICAgIGlmIG5vdCBzZWxmLmVtYmVkZGluZ19pbmRleCBvciBub3Qgc2VsZi5lbWJlZGRpbmdfaW5kZXgubG9hZGVkOgogICAgICAgICAgICByZXR1cm4gW10KCiAgICAgICAgIyBVc2UgdGhlIGVtYmVkZGluZ3Mgc2VhcmNoIHRvIGZpbmQgc2ltaWxhciBjYXJkcwogICAgICAgIG1hdGNoZXMgPSBzZWxmLmVtYmVkZGluZ19pbmRleC5zZWFyY2hfc2ltaWxhcigKICAgICAgICAgICAgcXVlcnlfY2FyZD1jYXJkLm5hbWUsCiAgICAgICAgICAgIGNvbG9yX2ZpbHRlcj1jb2xvcl9pZGVudGl0eSBpZiBjb2xvcl9pZGVudGl0eSBlbHNlIE5vbmUsCiAgICAgICAgICAgIHR5cGVfZmlsdGVyPWNhcmQuY2F0ZWdvcnkgaWYgY2FyZC5jYXRlZ29yeSBlbHNlIE5vbmUsCiAgICAgICAgICAgIGV4Y2x1ZGVfY2FyZHM9W2NhcmQubmFtZV0sCiAgICAgICAgICAgIHRvcF9uPVNVQlNUSVRVVElPTl9NQVhfQUxURVJOQVRJVkVTICogMywgICMgT3Zlci1mZXRjaCwgdGhlbiBmaWx0ZXIgdG8gb3duZWQKICAgICAgICApCgogICAgICAgICMgRmlsdGVyIHRvIG9ubHkgY2FyZHMgaW4gdGhlIGF2YWlsYWJsZSBwb29sCiAgICAgICAgcG9vbF9sb3dlciA9IHNldChuLmxvd2VyKCkgZm9yIG4gaW4gYXZhaWxhYmxlX3Bvb2wpCiAgICAgICAgYWx0ZXJuYXRpdmVzID0gW10KICAgICAgICBmb3IgbWF0Y2ggaW4gbWF0Y2hlczoKICAgICAgICAgICAgaWYgbWF0Y2gubmFtZS5sb3dlcigpIGluIHBvb2xfbG93ZXI6CiAgICAgICAgICAgICAgICAjIENvbXB1dGUgcm9sZSBvdmVybGFwCiAgICAgICAgICAgICAgICBtYXRjaF9yb2xlcyA9IFtdICAjIFdvdWxkIG5lZWQgY2FyZCdzIHJvbGVfdGFncyBmcm9tIERCCiAgICAgICAgICAgICAgICByb2xlX292ZXJsYXAgPSBsaXN0KHNldChjYXJkLnJvbGVfdGFncykgJiBzZXQobWF0Y2hfcm9sZXMpKSBpZiBtYXRjaF9yb2xlcyBlbHNlIFtdCgogICAgICAgICAgICAgICAgYWx0ZXJuYXRpdmVzLmFwcGVuZChDYXJkQWx0ZXJuYXRpdmUoCiAgICAgICAgICAgICAgICAgICAgbmFtZT1tYXRjaC5uYW1lLAogICAgICAgICAgICAgICAgICAgIHNpbWlsYXJpdHlfc2NvcmU9bWF0Y2guc2ltaWxhcml0eSwKICAgICAgICAgICAgICAgICAgICByZWFzb249ZiJTaW1pbGFyIHRvIHtjYXJkLm5hbWV9IChjb3NpbmUgc2ltOiB7bWF0Y2guc2ltaWxhcml0eTouM2Z9KSIsCiAgICAgICAgICAgICAgICAgICAgc291cmNlPSJlbWJlZGRpbmciLAogICAgICAgICAgICAgICAgICAgIHJvbGVfb3ZlcmxhcD1yb2xlX292ZXJsYXAsCiAgICAgICAgICAgICAgICAgICAgY21jX2RlbHRhPW1hdGNoLm1hbmFfdmFsdWUgLSAoY2FyZC5lc3RpbWF0ZWRfcHJpY2VfdXNkIG9yIDApLAogICAgICAgICAgICAgICAgKSkKCiAgICAgICAgICAgICAgICBpZiBsZW4oYWx0ZXJuYXRpdmVzKSA+PSBTVUJTVElUVVRJT05fTUFYX0FMVEVSTkFUSVZFUzoKICAgICAgICAgICAgICAgICAgICBicmVhawoKICAgICAgICByZXR1cm4gYWx0ZXJuYXRpdmVzCgogICAgZGVmIF9wcGx4X3N1YnN0aXR1dGlvbigKICAgICAgICBzZWxmLAogICAgICAgIG1pc3NpbmdfY2FyZHM6IExpc3RbRGVja0NhcmRXaXRoU3RhdHVzXSwKICAgICAgICBhdmFpbGFibGVfcG9vbDogTGlzdFtzdHJdLAogICAgICAgIGNvbW1hbmRlcjogZGljdCwKICAgICAgICBzdHJhdGVneTogc3RyLAogICAgKToKICAgICAgICAiIiIKICAgICAgICBCYXRjaCBQZXJwbGV4aXR5IGZhbGxiYWNrIGZvciBjYXJkcyB3aXRoIGxvdyBlbWJlZGRpbmcgY29uZmlkZW5jZS4KICAgICAgICBNdXRhdGVzIHRoZSBjYXJkcyBpbi1wbGFjZSB3aXRoIHN1YnN0aXR1dGlvbiBkYXRhLgogICAgICAgICIiIgogICAgICAgIGlmIG5vdCBhdmFpbGFibGVfcG9vbDoKICAgICAgICAgICAgcmV0dXJuCgogICAgICAgICMgQnVpbGQgdGhlIHJlcXVlc3QKICAgICAgICBtaXNzaW5nX2RpY3RzID0gWwogICAgICAgICAgICB7CiAgICAgICAgICAgICAgICAibmFtZSI6IGMubmFtZSwKICAgICAgICAgICAgICAgICJyb2xlX3RhZ3MiOiBjLnJvbGVfdGFncywKICAgICAgICAgICAg' +
'ICAgICJjYXRlZ29yeSI6IGMuY2F0ZWdvcnksCiAgICAgICAgICAgICAgICAicmVhc29uIjogYy5yZWFzb24sCiAgICAgICAgICAgIH0KICAgICAgICAgICAgZm9yIGMgaW4gbWlzc2luZ19jYXJkcwogICAgICAgIF0KCiAgICAgICAgdXNlcl9wcm9tcHQgPSBidWlsZF9zdWJzdGl0dXRpb25fcHJvbXB0KAogICAgICAgICAgICBtaXNzaW5nX2NhcmRzPW1pc3NpbmdfZGljdHMsCiAgICAgICAgICAgIGFsbG93ZWRfY2FyZHM9YXZhaWxhYmxlX3Bvb2xbOjIwMF0sICAjIExpbWl0IHRvIGF2b2lkIHRva2VuIG92ZXJmbG93CiAgICAgICAgICAgIGNvbW1hbmRlcj1jb21tYW5kZXIuZ2V0KCJuYW1lIiwgIiIpLAogICAgICAgICAgICBjb2xvcl9pZGVudGl0eT1jb21tYW5kZXIuZ2V0KCJjb2xvcl9pZGVudGl0eSIsIFtdKSwKICAgICAgICAgICAgc3RyYXRlZ3k9c3RyYXRlZ3ksCiAgICAgICAgKQoKICAgICAgICB0cnk6CiAgICAgICAgICAgIGZyb20gLi5zY2hlbWFzLnN1YnN0aXR1dGlvbl9zY2hlbWEgaW1wb3J0IFNVQlNUSVRVVElPTl9SRVNQT05TRV9TQ0hFTUEKICAgICAgICAgICAgcmVzcG9uc2UgPSBzZWxmLnBwbHguY2hhdF9zdHJ1Y3R1cmVkKAogICAgICAgICAgICAgICAgc3lzdGVtX3Byb21wdD1TVUJTVElUVVRJT05fU1lTVEVNLAogICAgICAgICAgICAgICAgdXNlcl9wcm9tcHQ9dXNlcl9wcm9tcHQsCiAgICAgICAgICAgICAgICBqc29uX3NjaGVtYT1TVUJTVElUVVRJT05fUkVTUE9OU0VfU0NIRU1BLAogICAgICAgICAgICAgICAgc2NoZW1hX25hbWU9IlN1YnN0aXR1dGlvblJlc3BvbnNlIiwKICAgICAgICAgICAgICAgIG1vZGVsPVNVQlNUSVRVVElPTl9NT0RFTCwKICAgICAgICAgICAgICAgIHRlbXBlcmF0dXJlPTAuMSwKICAgICAgICAgICAgICAgIG1heF90b2tlbnM9NDA5NiwKICAgICAgICAgICAgKQoKICAgICAgICAgICAgaWYgbm90IHJlc3BvbnNlLm9rOgogICAgICAgICAgICAgICAgbG9nZ2VyLndhcm5pbmcoIlBlcnBsZXhpdHkgc3Vic3RpdHV0aW9uIGZhbGxiYWNrIGZhaWxlZCDigJQgbm9uLUpTT04gcmVzcG9uc2UiKQogICAgICAgICAgICAgICAgcmV0dXJuCgogICAgICAgICAgICBzdWJzID0gcmVzcG9uc2UucGFyc2VkX2pzb24uZ2V0KCJzdWJzdGl0dXRpb25zIiwgW10pCiAgICAgICAgICAgIGxvZ2dlci5pbmZvKCJQZXJwbGV4aXR5IHN1Z2dlc3RlZCAlZCBzdWJzdGl0dXRpb25zIiwgbGVuKHN1YnMpKQoKICAgICAgICAgICAgIyBBcHBseSBzdWJzdGl0dXRpb25zCiAgICAgICAgICAgIGNhcmRfYnlfbmFtZSA9IHtjLm5hbWUubG93ZXIoKTogYyBmb3IgYyBpbiBtaXNzaW5nX2NhcmRzfQogICAgICAgICAgICBmb3Igc3ViIGluIHN1YnM6CiAgICAgICAgICAgICAgICBvcmlnID0gc3ViLmdldCgib3JpZ2luYWwiLCAiIikubG93ZXIoKQogICAgICAgICAgICAgICAgc3Vic3RpdHV0ZSA9IHN1Yi5nZXQoInN1YnN0aXR1dGUiLCAiIikKICAgICAgICAgICAgICAgIHJlYXNvbiA9IHN1Yi5nZXQoInJlYXNvbiIsICIiKQoKICAgICAgICAgICAgICAgIGlmIG9yaWcgaW4gY2FyZF9ieV9uYW1lIGFuZCBzdWJzdGl0dXRlOgogICAgICAgICAgICAgICAgICAgIGNhcmQgPSBjYXJkX2J5X25hbWVbb3JpZ10KICAgICAgICAgICAgICAgICAgICAjIEFkZCBhcyB0b3AgYWx0ZXJuYXRpdmUKICAgICAgICAgICAgICAgICAgICBwcGx4X2FsdCA9IENhcmRBbHRlcm5hdGl2ZSgKICAgICAgICAgICAgICAgICAgICAgICAgbmFtZT1zdWJzdGl0dXRlLAogICAgICAgICAgICAgICAgICAgICAgICBzaW1pbGFyaXR5X3Njb3JlPTAuODAsICAjIE5vbWluYWwgc2NvcmUgZm9yIFBlcnBsZXhpdHkgc3VnZ2VzdGlvbnMKICAgICAgICAgICAgICAgICAgICAgICAgcmVhc29uPXJlYXNvbiwKICAgICAgICAgICAgICAgICAgICAgICAgc291cmNlPSJwZXJwbGV4aXR5IiwKICAgICAgICAgICAgICAgICAgICAgICAgcm9sZV9vdmVybGFwPXN1Yi5nZXQoInJvbGVfb3ZlcmxhcCIsIFtdKSwKICAgICAgICAgICAgICAgICAgICApCiAgICAgICAgICAgICAgICAgICAgIyBJbnNlcnQgYXQgZnJvbnQKICAgICAgICAgICAgICAgICAgICBjYXJkLmFsdGVybmF0aXZlcy5pbnNlcnQoMCwgcHBseF9hbHQpCiAgICAgICAgICAgICAgICAgICAgY2FyZC5zZWxlY3RlZF9zdWJzdGl0dXRlID0gc3Vic3RpdHV0ZQogICAgICAgICAgICAgICAgICAgIGNhcmQuc3RhdHVzID0gInN1YnN0aXR1dGVkIgoKICAgICAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgICAg' +
'IGxvZ2dlci5lcnJvcigiUGVycGxleGl0eSBzdWJzdGl0dXRpb24gZmFsbGJhY2sgZXJyb3I6ICVzIiwgZSkKCiAgICAjIOKUgOKUgCBTdGF0cyDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKICAgIEBzdGF0aWNtZXRob2QKICAgIGRlZiBfY29tcHV0ZV9zdGF0cyhjYXJkczogTGlzdFtEZWNrQ2FyZFdpdGhTdGF0dXNdKSAtPiBkaWN0OgogICAgICAgICIiIkNvbXB1dGUgZGVjayBzdGF0aXN0aWNzIGZyb20gdGhlIGVucmljaGVkIGNhcmQgbGlzdC4iIiIKICAgICAgICBieV9jYXRlZ29yeSA9IHt9CiAgICAgICAgYnlfc3RhdHVzID0geyJvd25lZCI6IDAsICJzdWJzdGl0dXRlZCI6IDAsICJtaXNzaW5nIjogMH0KICAgICAgICB0b3RhbF9wcmljZSA9IDAuMAogICAgICAgIG93bmVkX3ByaWNlID0gMC4wCiAgICAgICAgbWlzc2luZ19wcmljZSA9IDAuMAogICAgICAgIHJvbGVfY291bnRzID0ge30KCiAgICAgICAgZm9yIGNhcmQgaW4gY2FyZHM6CiAgICAgICAgICAgIGNhdCA9IGNhcmQuY2F0ZWdvcnkgb3IgIk90aGVyIgogICAgICAgICAgICBieV9jYXRlZ29yeVtjYXRdID0gYnlfY2F0ZWdvcnkuZ2V0KGNhdCwgMCkgKyBjYXJkLmNvdW50CiAgICAgICAgICAgIGJ5X3N0YXR1c1tjYXJkLnN0YXR1c10gPSBieV9zdGF0dXMuZ2V0KGNhcmQuc3RhdHVzLCAwKSArIDEKICAgICAgICAgICAgcHJpY2UgPSBjYXJkLmVzdGltYXRlZF9wcmljZV91c2QgKiBjYXJkLmNvdW50CiAgICAgICAgICAgIHRvdGFsX3ByaWNlICs9IHByaWNlCiAgICAgICAgICAgIGlmIGNhcmQub3duZWQ6CiAgICAgICAgICAgICAgICBvd25lZF9wcmljZSArPSBwcmljZQogICAgICAgICAgICBlbHNlOgogICAgICAgICAgICAgICAgbWlzc2luZ19wcmljZSArPSBwcmljZQogICAgICAgICAgICBmb3Igcm9sZSBpbiBjYXJkLnJvbGVfdGFnczoKICAgICAgICAgICAgICAgIHJvbGVfY291bnRzW3JvbGVdID0gcm9sZV9jb3VudHMuZ2V0KHJvbGUsIDApICsgMQoKICAgICAgICBsYW5kX2NvdW50ID0gYnlfY2F0ZWdvcnkuZ2V0KCJMYW5kIiwgMCkKICAgICAgICBub25sYW5kX2NvdW50ID0gc3VtKHYgZm9yIGssIHYgaW4gYnlfY2F0ZWdvcnkuaXRlbXMoKSBpZiBrICE9ICJMYW5kIikKCiAgICAgICAgcmV0dXJuIHsKICAgICAgICAgICAgInRvdGFsX2NhcmRzIjogc3VtKGMuY291bnQgZm9yIGMgaW4gY2FyZHMpLAogICAgICAgICAgICAibGFuZF9jb3VudCI6IGxhbmRfY291bnQsCiAgICAgICAgICAgICJub25sYW5kX2NvdW50Ijogbm9ubGFuZF9jb3VudCwKICAgICAgICAgICAgImJ5X2NhdGVnb3J5IjogYnlfY2F0ZWdvcnksCiAgICAgICAgICAgICJieV9zdGF0dXMiOiBieV9zdGF0dXMsCiAgICAgICAgICAgICJyb2xlX2NvdW50cyI6IHJvbGVfY291bnRzLAogICAgICAgICAgICAidG90YWxfcHJpY2VfdXNkIjogcm91bmQodG90YWxfcHJpY2UsIDIpLAogICAgICAgICAgICAib3duZWRfcHJpY2VfdXNkIjogcm91bmQob3duZWRfcHJpY2UsIDIpLAogICAgICAgICAgICAibWlzc2luZ19wcmljZV91c2QiOiByb3VuZChtaXNzaW5nX3ByaWNlLCAyKSwKICAgICAgICB9Cg=='
Install-B64 -R 'coach\services\deck_generator.py' -B $b
# deck_sources/__init__.py (236 bytes)
@@ -539,7 +536,7 @@ Install-B64 -R 'frontend\commander-ai-lab-ui\tsconfig.node.json' -B $b
$b = 'aW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSAndml0ZScKaW1wb3J0IHJlYWN0IGZyb20gJ0B2aXRlanMvcGx1Z2luLXJlYWN0JwppbXBvcnQgdGFpbHdpbmRjc3MgZnJvbSAnQHRhaWx3aW5kY3NzL3ZpdGUnCgpleHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoewogIHBsdWdpbnM6IFtyZWFjdCgpLCB0YWlsd2luZGNzcygpXSwKICBzZXJ2ZXI6IHsKICAgIHBvcnQ6IDUxNzMsCiAgICBwcm94eTogewogICAgICAnL2FwaSc6IHsKICAgICAgICB0YXJnZXQ6ICdodHRwOi8vbG9jYWxob3N0OjgwODAnLAogICAgICAgIGNoYW5nZU9yaWdpbjogdHJ1ZSwKICAgICAgfSwKICAgIH0sCiAgfSwKICBidWlsZDogewogICAgb3V0RGlyOiAnZGlzdCcsCiAgICBzb3VyY2VtYXA6IGZhbHNlLAogICAgcm9sbHVwT3B0aW9uczogewogICAgICBvdXRwdXQ6IHsKICAgICAgICBtYW51YWxDaHVua3M6IHsKICAgICAgICAgIHJlY2hhcnRzOiBbJ3JlY2hhcnRzJ10sCiAgICAgICAgICByZWFjdDogWydyZWFjdCcsICdyZWFjdC1kb20nLCAncmVhY3Qtcm91dGVyLWRvbSddLAogICAgICAgIH0sCiAgICAgIH0sCiAgICB9LAogIH0sCn0pCg=='
Install-B64 -R 'frontend\commander-ai-lab-ui\vite.config.ts' -B $b
# lab_api.py (326,001 bytes)
# lab_api.py (322,143 bytes)
$b = 'IyEvdXNyL2Jpbi9lbnYgcHl0aG9uMwoiIiIKQ29tbWFuZGVyIEFJIExhYiDigJQgRmFzdEFQSSBCYWNrZW5kIHYzCuKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAoKdjMgYWRkczoKICBQT1NUIC9hcGkvbGFiL2ltcG9ydC91cmwgICAgICAg4oCUIEltcG9ydCBkZWNrIGZyb20gQXJjaGlkZWt0L0VESFJFQyBVUkwKICBQT1NUIC9hcGkvbGFiL2ltcG9ydC90ZXh0ICAgICAg4oCUIEltcG9ydCBkZWNrIGZyb20gY2FyZCBsaXN0IHRleHQKICBHRVQgIC9hcGkvbGFiL21ldGEvY29tbWFuZGVycyAg4oCUIExpc3QgYXZhaWxhYmxlIGNvbW1hbmRlcnMgaW4gbWV0YSBtYXBwaW5nCiAgR0VUICAvYXBpL2xhYi9tZXRhL3NlYXJjaCAgICAgIOKAlCBTZWFyY2ggY29tbWFuZGVycyBieSBuYW1lCiAgUE9TVCAvYXBpL2xhYi9tZXRhL2ZldGNoICAgICAgIOKAlCBGZXRjaCBFREhSRUMgYXZlcmFnZSBkZWNrIGZvciBhIGNvbW1hbmRlcgogIFBPU1QgL2FwaS9sYWIvc3RhcnQgICAgICAgICAgICDigJQgRXh0ZW5kZWQ6IGFjY2VwdHMgaW1wb3J0ZWQgZGVjayBwcm9maWxlcwoKUnVucyBvbiBwb3J0IDgwODAgYnkgZGVmYXVsdC4gU2VydmVzIHRoZSB3ZWIgVUkgc3RhdGljIGZpbGVzIGF0IC8uCiIiIgoKaW1wb3J0IGFyZ3BhcnNlCmltcG9ydCBhc3luY2lvCmltcG9ydCBjc3YKaW1wb3J0IGlvCmltcG9ydCBqc29uCmltcG9ydCBvcwppbXBvcnQgcmUKaW1wb3J0IHNxbGl0ZTMKaW1wb3J0IHN1YnByb2Nlc3MKaW1wb3J0IHN5cwppbXBvcnQgdGhyZWFkaW5nCmltcG9ydCB0aW1lCmltcG9ydCB1dWlkCmZyb20gZGF0ZXRpbWUgaW1wb3J0IGRhdGV0aW1lCmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aApmcm9tIHR5cGluZyBpbXBvcnQgT3B0aW9uYWwKZnJvbSB1cmxsaWIucmVxdWVzdCBpbXBvcnQgdXJsb3BlbiwgUmVxdWVzdApmcm9tIHVybGxpYi5lcnJvciBpbXBvcnQgVVJMRXJyb3IKCnRyeToKICAgIGZyb20gZmFzdGFwaSBpbXBvcnQgRmFzdEFQSSwgSFRUUEV4Y2VwdGlvbiwgQmFja2dyb3VuZFRhc2tzLCBSZXF1ZXN0IGFzIEZhc3RBUElSZXF1ZXN0CiAgICBmcm9tIGZhc3RhcGkubWlkZGxld2FyZS5jb3JzIGltcG9ydCBDT1JTTWlkZGxld2FyZQogICAgZnJvbSBmYXN0YXBpLnJlc3BvbnNlcyBpbXBvcnQgRmlsZVJlc3BvbnNlLCBKU09OUmVzcG9uc2UsIFN0cmVhbWluZ1Jlc3BvbnNlCiAgICBmcm9tIGZhc3RhcGkuc3RhdGljZmlsZXMgaW1wb3J0IFN0YXRpY0ZpbGVzCiAgICBmcm9tIHB5ZGFudGljIGltcG9ydCBCYXNlTW9kZWwKZXhjZXB0IEltcG9ydEVycm9yOgogICAgcHJpbnQoIkVSUk9SOiBGYXN0QVBJIG5vdCBpbnN0YWxsZWQuIFJ1bjogcGlwIGluc3RhbGwgZmFzdGFwaSB1dmljb3JuIikKICAgIHN5cy5leGl0KDEpCgoKIyDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZAKIyBDb25maWd1cmF0aW9uCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCgpjbGFzcyBDb25maWc6CiAgICAiIiJSdW50aW1lIGNvbmZpZ3VyYXRpb24g4oCUIHNldCB2aWEgQ0xJIGFyZ3Mgb3IgZW52IHZhcnMuIiIiCiAgICBmb3JnZV9qYXI6IHN0ciA9ICIiCiAgICBmb3JnZV9kaXI6IHN0ciA9ICIiCiAgICBmb3JnZV9kZWNrc19kaXI6IHN0ciA9ICIiCiAgICBsYWJfamFyOiBzdHIgPSAiIgogICAgcmVzdWx0c19kaXI6IHN0ciA9ICJyZXN1bHRzIgogICAgcG9ydDogaW50ID0gODA4MAogICAgeGltaWxhcl9hcGlfa2V5OiBzdHIgPSAiIgogICAgcHBseF9hcGlfa2V5OiBzdHIgPSAiIiAgIyBQZXJwbGV4aXR5IEFQSSBrZXkgZm9yIGRlY2sgcmVzZWFyY2gvZ2VuZXJhdGlvbgoKQ0ZHID0gQ29uZmlnKCkKCgojIOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKV' +
'kOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAojIEFwcCBTZXR1cAojIOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAoKYXBwID0gRmFzdEFQSSh0aXRsZT0iQ29tbWFuZGVyIEFJIExhYiBBUEkiLCB2ZXJzaW9uPSIzLjAuMCIpCgphcHAuYWRkX21pZGRsZXdhcmUoCiAgICBDT1JTTWlkZGxld2FyZSwKICAgIGFsbG93X29yaWdpbnM9WyIqIl0sCiAgICBhbGxvd19tZXRob2RzPVsiKiJdLAogICAgYWxsb3dfaGVhZGVycz1bIioiXSwKKQoKCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCiMgSW4tTWVtb3J5IFN0YXRlCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCgpjbGFzcyBCYXRjaFN0YXRlOgogICAgIiIiVHJhY2tzIGEgcnVubmluZyBvciBjb21wbGV0ZWQgYmF0Y2guIiIiCiAgICBkZWYgX19pbml0X18oc2VsZiwgYmF0Y2hfaWQ6IHN0ciwgdG90YWxfZ2FtZXM6IGludCwgdGhyZWFkczogaW50KToKICAgICAgICBzZWxmLmJhdGNoX2lkID0gYmF0Y2hfaWQKICAgICAgICBzZWxmLnRvdGFsX2dhbWVzID0gdG90YWxfZ2FtZXMKICAgICAgICBzZWxmLnRocmVhZHMgPSB0aHJlYWRzCiAgICAgICAgc2VsZi5jb21wbGV0ZWRfZ2FtZXMgPSAwCiAgICAgICAgc2VsZi5ydW5uaW5nID0gVHJ1ZQogICAgICAgIHNlbGYuc3RhcnRfdGltZSA9IGRhdGV0aW1lLm5vdygpCiAgICAgICAgc2VsZi5lbGFwc2VkX21zID0gMAogICAgICAgIHNlbGYucmVzdWx0X3BhdGg6IE9wdGlvbmFsW3N0cl0gPSBOb25lCiAgICAgICAgc2VsZi5lcnJvcjogT3B0aW9uYWxbc3RyXSA9IE5vbmUKICAgICAgICBzZWxmLnByb2Nlc3M6IE9wdGlvbmFsW3N1YnByb2Nlc3MuUG9wZW5dID0gTm9uZQogICAgICAgIHNlbGYubG9nX2xpbmVzOiBsaXN0ID0gW10KICAgICAgICAjIHYyNCAoSXNzdWUgIzUpOiBSZWFsLXRpbWUgdGhyb3VnaHB1dCB0cmFja2luZwogICAgICAgIHNlbGYuc2ltc19wZXJfc2VjOiBmbG9hdCA9IDAuMAoKYWN0aXZlX2JhdGNoZXM6IGRpY3Rbc3RyLCBCYXRjaFN0YXRlXSA9IHt9CgojIENvbW1hbmRlciBtZXRhIG1hcHBpbmcgKGxvYWRlZCBhdCBzdGFydHVwKQpDT01NQU5ERVJfTUVUQTogZGljdCA9IHt9CgojIOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAojIFJlcXVlc3QgLyBSZXNwb25zZSBNb2RlbHMKIyDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZAKCmNsYXNzIFN0YXJ0UmVxdWVzdChCYXNlTW9kZWwpOgogICAgZGVja3M6IGxpc3Rbc3RyXSAgICAgICAgICAgIyBFeGFjdGx5IDMgZGVjayBuYW1lcwogICAgbnVtR2FtZXM6IGludCA9IDEwMAogICAgdGhyZWFkczogaW50ID0gNAogICAgc2VlZDogT3B0aW9u' +
'YWxbaW50XSA9IE5vbmUKICAgIGNsb2NrOiBpbnQgPSAxMjAKICAgICMgdjM6IE9wdGlvbmFsIHNvdXJjZSBtZXRhZGF0YSBwZXIgZGVjawogICAgZGVja1NvdXJjZXM6IE9wdGlvbmFsW2xpc3RbT3B0aW9uYWxbZGljdF1dXSA9IE5vbmUgICMgW3tzb3VyY2UsIHNvdXJjZVVybCwgY29tbWFuZGVyLCBhcmNoZXR5cGV9XQogICAgIyB2ODogTGVhcm5lZCBwb2xpY3kgc3VwcG9ydAogICAgdXNlTGVhcm5lZFBvbGljeTogYm9vbCA9IEZhbHNlCiAgICBwb2xpY3lTdHlsZTogc3RyID0gIm1pZHJhbmdlIgogICAgcG9saWN5R3JlZWR5OiBib29sID0gRmFsc2UKICAgICMgdjI0IChJc3N1ZXMgIzQvIzUpOiBQZXJmb3JtYW5jZSBzZXR0aW5ncwogICAgYWlTaW1wbGlmaWVkOiBib29sID0gRmFsc2UgICAgICAgICAjIFVzZSBmYXN0ZXIgQUkgcHJvZmlsZQogICAgYWlUaGlua1RpbWVNczogaW50ID0gLTEgICAgICAgICAgICAjIENhcCBBSSB0aGluayB0aW1lICgtMSB1bmxpbWl0ZWQpCiAgICBtYXhRdWV1ZURlcHRoOiBpbnQgPSAtMSAgICAgICAgICAgICMgQmFja3ByZXNzdXJlIGxpbWl0ICgtMSB1bmxpbWl0ZWQpCgpjbGFzcyBTdGFydFJlc3BvbnNlKEJhc2VNb2RlbCk6CiAgICBiYXRjaElkOiBzdHIKICAgIHN0YXR1czogc3RyID0gInN0YXJ0ZWQiCiAgICBtZXNzYWdlOiBzdHIgPSAiIgoKY2xhc3MgU3RhdHVzUmVzcG9uc2UoQmFzZU1vZGVsKToKICAgIGJhdGNoSWQ6IHN0ciA9ICIiCiAgICBydW5uaW5nOiBib29sID0gRmFsc2UKICAgIGNvbXBsZXRlZDogaW50ID0gMAogICAgdG90YWw6IGludCA9IDAKICAgIHRocmVhZHM6IGludCA9IDAKICAgIGVsYXBzZWRNczogaW50ID0gMAogICAgZXJyb3I6IE9wdGlvbmFsW3N0cl0gPSBOb25lCiAgICBzaW1zUGVyU2VjOiBmbG9hdCA9IDAuMAogICAgIyBSZWFjdCBTUEEgYWxpYXNlcyAoc2FtZSBkYXRhLCBleHBlY3RlZCBmaWVsZCBuYW1lcykKICAgIHJ1bl9pZDogc3RyID0gIiIKICAgIGdhbWVzX2NvbXBsZXRlZDogaW50ID0gMAogICAgdG90YWxfZ2FtZXM6IGludCA9IDAKICAgIGN1cnJlbnRfZGVja3M6IGxpc3QgPSBbXQoKY2xhc3MgRGVja0luZm8oQmFzZU1vZGVsKToKICAgIG5hbWU6IHN0cgogICAgZmlsZW5hbWU6IHN0cgoKY2xhc3MgSW1wb3J0VXJsUmVxdWVzdChCYXNlTW9kZWwpOgogICAgdXJsOiBzdHIKCmNsYXNzIEltcG9ydFRleHRSZXF1ZXN0KEJhc2VNb2RlbCk6CiAgICB0ZXh0OiBzdHIKICAgIGNvbW1hbmRlcjogT3B0aW9uYWxbc3RyXSA9IE5vbmUKCmNsYXNzIE1ldGFGZXRjaFJlcXVlc3QoQmFzZU1vZGVsKToKICAgIGNvbW1hbmRlcjogc3RyCgoKIyDilIDilIAgRGVjayBCdWlsZGVyIE1vZGVscyDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKCmNsYXNzIENyZWF0ZURlY2tSZXF1ZXN0KEJhc2VNb2RlbCk6CiAgICBuYW1lOiBzdHIKICAgIGNvbW1hbmRlcl9zY3J5ZmFsbF9pZDogT3B0aW9uYWxbc3RyXSA9ICIiCiAgICBjb21tYW5kZXJfbmFtZTogT3B0aW9uYWxbc3RyXSA9ICIiCiAgICBjb2xvcl9pZGVudGl0eTogT3B0aW9uYWxbbGlzdF0gPSBbXQogICAgc3RyYXRlZ3lfdGFnOiBPcHRpb25hbFtzdHJdID0gIiIKCgpjbGFzcyBVcGRhdGVEZWNrUmVxdWVzdChCYXNlTW9kZWwpOgogICAgbmFtZTogT3B0aW9uYWxbc3RyXSA9IE5vbmUKICAgIGNvbW1hbmRlcl9zY3J5ZmFsbF9pZDogT3B0aW9uYWxbc3RyXSA9IE5vbmUKICAgIGNvbW1hbmRlcl9uYW1lOiBPcHRpb25hbFtzdHJdID0gTm9uZQogICAgY29sb3JfaWRlbnRpdHk6IE9wdGlvbmFsW2xpc3RdID0gTm9uZQogICAgc3RyYXRlZ3lfdGFnOiBPcHRpb25hbFtzdHJdID0gTm9uZQoKCmNsYXNzIEFkZERlY2tDYXJkUmVxdWVzdChCYXNlTW9kZWwpOgogICAgc2NyeWZhbGxfaWQ6IHN0cgogICAgY2FyZF9uYW1lOiBPcHRpb25hbFtzdHJdID0gIiIKICAgIHF1YW50aXR5OiBPcHRpb25hbFtpbnRdID0gMQogICAgaXNfY29tbWFuZGVyOiBPcHRpb25hbFtpbnRdID0gMAogICAgcm9sZV90YWc6IE9wdGlvbmFsW3N0cl0gPSAiIgoKCmNsYXNzIFBhdGNoRGVja0NhcmRSZXF1ZXN0KEJhc2VNb2RlbCk6CiAgICBxdWFudGl0eTogT3B0aW9uYWxbaW50XSA9IE5vbmUKICAgIHJvbGVfdGFnOiBPcHRpb25hbFtzdHJdID0gTm9uZQoKCmNsYXNzIEJ1bGtBZGRSZXF1ZXN0KEJh' +
@@ -661,30 +658,29 @@ $b = 'IyEvdXNyL2Jpbi9lbnYgcHl0aG9uMwoiIiIKQ29tbWFuZGVyIEFJIExhYiDigJQgRmFzdEFQSS
'ZXR1cm4gaW4gTW94ZmllbGQgcGFzdGUgZm9ybWF0LiIiIgogICAgaWYgX2RlY2tfZ2VuX3YzIGlzIE5vbmU6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig1MDMsICdWMyBEZWNrIEdlbmVyYXRvciBub3QgaW5pdGlhbGl6ZWQuJykKCiAgICByZXN1bHQgPSBfZGVja19nZW5fdjMuZ2VuZXJhdGVfZGVjaygKICAgICAgICBjb21tYW5kZXJfbmFtZT1yZXEuY29tbWFuZGVyX25hbWUuc3RyaXAoKSwKICAgICAgICBzdHJhdGVneT1yZXEuc3RyYXRlZ3ksCiAgICAgICAgdGFyZ2V0X2JyYWNrZXQ9cmVxLnRhcmdldF9icmFja2V0LAogICAgICAgIGJ1ZGdldF91c2Q9cmVxLmJ1ZGdldF91c2QsCiAgICAgICAgYnVkZ2V0X21vZGU9cmVxLmJ1ZGdldF9tb2RlLAogICAgICAgIG9taXRfY2FyZHM9cmVxLm9taXRfY2FyZHMsCiAgICAgICAgdXNlX2NvbGxlY3Rpb249cmVxLnVzZV9jb2xsZWN0aW9uLAogICAgICAgIG1vZGVsPXJlcS5tb2RlbCwKICAgICkKCiAgICBjYXJkcyA9IHJlc3VsdC5nZXQoJ2NhcmRzJywgW10pCiAgICBjb21tYW5kZXIgPSByZXN1bHQuZ2V0KCdjb21tYW5kZXInLCB7fSkKCiAgICAjIE1veGZpZWxkIGZvcm1hdDogY2FyZCBsaW5lcyBpbiBtYWluLCBjb21tYW5kZXIgaW4gZGVkaWNhdGVkIHNlY3Rpb24KICAgIGxpbmVzID0gW10KICAgIGxpbmVzLmFwcGVuZCgnLy8gQ29tbWFuZGVyJykKICAgIGxpbmVzLmFwcGVuZChmJzEge2NvbW1hbmRlci5nZXQoIm5hbWUiLCAiIil9JykKICAgIGxpbmVzLmFwcGVuZCgnJykKICAgIGxpbmVzLmFwcGVuZCgnLy8gRGVjaycpCiAgICBmb3IgY2FyZCBpbiBjYXJkczoKICAgICAgICBjbmFtZSA9IGNhcmQuZ2V0KCduYW1lJywgJycpCiAgICAgICAgaWYgY2FyZC5nZXQoJ3N0YXR1cycpID09ICdzdWJzdGl0dXRlZCcgYW5kIGNhcmQuZ2V0KCdzZWxlY3RlZF9zdWJzdGl0dXRlJyk6CiAgICAgICAgICAgIGNuYW1lID0gY2FyZFsnc2VsZWN0ZWRfc3Vic3RpdHV0ZSddCiAgICAgICAgaWYgY25hbWUgYW5kIGNuYW1lICE9IGNvbW1hbmRlci5nZXQoJ25hbWUnLCAnJyk6CiAgICAgICAgICAgIGxpbmVzLmFwcGVuZChmIntjYXJkLmdldCgnY291bnQnLCAxKX0ge2NuYW1lfSIpCgogICAgdHh0ID0gJ1xuJy5qb2luKGxpbmVzKQogICAgcmV0dXJuIHsnZm9ybWF0JzogJ21veGZpZWxkJywgJ2NvbnRlbnQnOiB0eHQsICdjb21tYW5kZXInOiBjb21tYW5kZXIuZ2V0KCduYW1lJywgJycpfQoKCkBhcHAucG9zdCgnL2FwaS9kZWNrL3YzL2V4cG9ydC9zaG9wcGluZycpCmFzeW5jIGRlZiBkZWNrX2dlbl92M19leHBvcnRfc2hvcHBpbmcocmVxOiBEZWNrR2VuVjNSZXF1ZXN0KToKICAgICIiIkdlbmVyYXRlIGEgZGVjayBhbmQgcmV0dXJuIGEgc2hvcHBpbmcgbGlzdCBvZiBjYXJkcyBub3Qgb3duZWQuIiIiCiAgICBpZiBfZGVja19nZW5fdjMgaXMgTm9uZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMywgJ1YzIERlY2sgR2VuZXJhdG9yIG5vdCBpbml0aWFsaXplZC4nKQoKICAgIHJlc3VsdCA9IF9kZWNrX2dlbl92My5nZW5lcmF0ZV9kZWNrKAogICAgICAgIGNvbW1hbmRlcl9uYW1lPXJlcS5jb21tYW5kZXJfbmFtZS5zdHJpcCgpLAogICAgICAgIHN0cmF0ZWd5PXJlcS5zdHJhdGVneSwKICAgICAgICB0YXJnZXRfYnJhY2tldD1yZXEudGFyZ2V0X2JyYWNrZXQsCiAgICAgICAgYnVkZ2V0X3VzZD1yZXEuYnVkZ2V0X3VzZCwKICAgICAgICBidWRnZXRfbW9kZT1yZXEuYnVkZ2V0X21vZGUsCiAgICAgICAgb21pdF9jYXJkcz1yZXEub21pdF9jYXJkcywKICAgICAgICB1c2VfY29sbGVjdGlvbj1yZXEudXNlX2NvbGxlY3Rpb24sCiAgICAgICAgbW9kZWw9cmVxLm1vZGVsLAogICAgKQoKICAgIGNhcmRzID0gcmVzdWx0LmdldCgnY2FyZHMnLCBbXSkKICAgIHNob3BwaW5nID0gW10KICAgIHRvdGFsID0gMC4wCiAgICBmb3IgY2FyZCBpbiBjYXJkczoKICAgICAgICBpZiBub3QgY2FyZC5nZXQoJ293bmVkJywgVHJ1ZSkgYW5kIGNhcmQuZ2V0KCdzdGF0dXMnKSAhPSAnc3Vic3RpdHV0ZWQnOgogICAgICAgICAgICBwcmljZSA9IGNhcmQuZ2V0KCdlc3RpbWF0ZWRfcHJpY2VfdXNkJywgMCkKICAgICAgICAgICAgc2hvcHBpbmcuYXBwZW5kKHsKICAgICAgICAgICAgICAgICduYW1lJzogY2FyZC5nZXQoJ25hbWUnLCAnJyksCiAgICAgICAgICAgICAgICAnY291bnQnOiBjYXJkLmdldCgnY291bnQnLCAxKSwKICAgICAgICAgICAgICAgICdjYXRlZ29yeSc6IGNhcmQuZ2V0KCdjYXRlZ29yeScsICcnKSwKICAg' +
'ICAgICAgICAgICAgICdlc3RpbWF0ZWRfcHJpY2VfdXNkJzogcHJpY2UsCiAgICAgICAgICAgICAgICAncmVhc29uJzogY2FyZC5nZXQoJ3JlYXNvbicsICcnKSwKICAgICAgICAgICAgfSkKICAgICAgICAgICAgdG90YWwgKz0gcHJpY2UgKiBjYXJkLmdldCgnY291bnQnLCAxKQoKICAgIHJldHVybiB7CiAgICAgICAgJ2NvbW1hbmRlcic6IHJlc3VsdC5nZXQoJ2NvbW1hbmRlcicsIHt9KS5nZXQoJ25hbWUnLCAnJyksCiAgICAgICAgJ3Nob3BwaW5nX2xpc3QnOiBzaG9wcGluZywKICAgICAgICAndG90YWxfbWlzc2luZyc6IGxlbihzaG9wcGluZyksCiAgICAgICAgJ2VzdGltYXRlZF9jb3N0X3VzZCc6IHJvdW5kKHRvdGFsLCAyKSwKICAgIH0KCgojIOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAojIENvYWNoIFNlcnZpY2UgKExMTS1wb3dlcmVkIGRlY2sgY29hY2hpbmcpCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCgojIEdsb2JhbCBjb2FjaCBpbnN0YW5jZXMgKGluaXRpYWxpemVkIGF0IHN0YXJ0dXApCl9jb2FjaF9zZXJ2aWNlID0gTm9uZQpfY29hY2hfZW1iZWRkaW5ncyA9IE5vbmUKX2NvYWNoX2xsbSA9IE5vbmUKX2RlY2tfZ2VuX3YzID0gTm9uZSAgIyBWMyBEZWNrIEdlbmVyYXRvciAoUGVycGxleGl0eSBzdHJ1Y3R1cmVkIG91dHB1dCkKX2RlY2tfZ2VuX3YzX2Vycm9yID0gTm9uZSAgIyBFcnJvciBtZXNzYWdlIGlmIFYzIGluaXQgZmFpbGVkCgoKZGVmIGluaXRfY29hY2hfc2VydmljZSgpOgogICAgIiIiSW5pdGlhbGl6ZSB0aGUgY29hY2ggc2VydmljZSB3aXRoIExMTSBjbGllbnQgYW5kIGVtYmVkZGluZ3MuIiIiCiAgICBnbG9iYWwgX2NvYWNoX3NlcnZpY2UsIF9jb2FjaF9lbWJlZGRpbmdzLCBfY29hY2hfbGxtLCBfZGVja19nZW5fdjMsIF9kZWNrX2dlbl92M19lcnJvcgogICAgdHJ5OgogICAgICAgIGZyb20gY29hY2gubGxtX2NsaWVudCBpbXBvcnQgTE1TdHVkaW9DbGllbnQKICAgICAgICBmcm9tIGNvYWNoLmVtYmVkZGluZ3MgaW1wb3J0IE1UR0VtYmVkZGluZ0luZGV4CiAgICAgICAgZnJvbSBjb2FjaC5jb2FjaF9zZXJ2aWNlIGltcG9ydCBDb2FjaFNlcnZpY2UKICAgICAgICBmcm9tIGNvYWNoLmNvbmZpZyBpbXBvcnQgZW5zdXJlX2RpcnMKCiAgICAgICAgZW5zdXJlX2RpcnMoKQoKICAgICAgICBfY29hY2hfbGxtID0gTE1TdHVkaW9DbGllbnQoKQogICAgICAgIF9jb2FjaF9lbWJlZGRpbmdzID0gTVRHRW1iZWRkaW5nSW5kZXgoKQoKICAgICAgICAjIFRyeSB0byBsb2FkIGVtYmVkZGluZ3MgKG5vbi1ibG9ja2luZyDigJQgZG93bmxvYWQgaGFwcGVucyBvbiBmaXJzdCB1c2UpCiAgICAgICAgdHJ5OgogICAgICAgICAgICBmcm9tIGNvYWNoLmNvbmZpZyBpbXBvcnQgRU1CRURESU5HU19OUFoKICAgICAgICAgICAgaWYgRU1CRURESU5HU19OUFouZXhpc3RzKCk6CiAgICAgICAgICAgICAgICBfY29hY2hfZW1iZWRkaW5ncy5sb2FkKCkKICAgICAgICAgICAgICAgIHByaW50KGYiICBDb2FjaDogICAgICAgIEVtYmVkZGluZ3MgbG9hZGVkICh7X2NvYWNoX2VtYmVkZGluZ3MuY2FyZF9jb3VudH0gY2FyZHMpIikKICAgICAgICAgICAgZWxzZToKICAgICAgICAgICAgICAgIHByaW50KCIgIENvYWNoOiAgICAgICAgRW1iZWRkaW5ncyBub3QgeWV0IGRvd25sb2FkZWQgKHdpbGwgZG93bmxvYWQgb24gZmlyc3QgdXNlKSIpCiAgICAgICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgICAgICBwcmludChmIiAgQ29hY2g6ICAgICAgICBFbWJlZGRpbmdzIGxvYWQgZmFpbGVkOiB7ZX0iKQoKICAgICAgICBfY29hY2hfc2VydmljZSA9IENvYWNoU2VydmljZShfY29hY2hfbGxtLCBfY29hY2hfZW1iZWRkaW5ncykKCiAgICAgICAgIyBDaGVjayBMTE0gY29ubmVjdGlvbgogICAgICAgIGxsbV9zdGF0dXMgPSBfY29hY2hfbGxtLmNoZWNrX2Nvbm5lY3Rpb24oKQog' +
'ICAgICAgIGlmIGxsbV9zdGF0dXMuZ2V0KCJjb25uZWN0ZWQiKToKICAgICAgICAgICAgcHJpbnQoZiIgIENvYWNoIExMTTogICAgQ29ubmVjdGVkICh7bGxtX3N0YXR1cy5nZXQoJ2FjdGl2ZV9tb2RlbCcsICd1bmtub3duJyl9KSIpCiAgICAgICAgZWxzZToKICAgICAgICAgICAgcHJpbnQoZiIgIENvYWNoIExMTTogICAgTm90IGNvbm5lY3RlZCAoc3RhcnQgTE0gU3R1ZGlvIG9uIDE5Mi4xNjguMC4xMjI6MTIzNCkiKQoKICAgICAgICBwcmludCgiICBDb2FjaDogICAgICAgIFNlcnZpY2UgaW5pdGlhbGl6ZWQiKQoKICAgICAgICAjIEluaXRpYWxpemUgVjMgRGVjayBHZW5lcmF0b3IgKFBlcnBsZXhpdHkpCiAgICAgICAgX2RlY2tfZ2VuX3YzX2Vycm9yID0gTm9uZQogICAgICAgIGlmIENGRy5wcGx4X2FwaV9rZXk6CiAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgIGZyb20gY29hY2guY2xpZW50cy5wZXJwbGV4aXR5X2NsaWVudCBpbXBvcnQgUGVycGxleGl0eUNsaWVudAogICAgICAgICAgICAgICAgZnJvbSBjb2FjaC5zZXJ2aWNlcy5kZWNrX2dlbmVyYXRvciBpbXBvcnQgRGVja0dlbmVyYXRvclYzCiAgICAgICAgICAgICAgICBmcm9tIGNvYWNoLmNvbmZpZyBpbXBvcnQgREVDS19HRU5fTU9ERUwKCiAgICAgICAgICAgICAgICBwcGx4X2NsaWVudCA9IFBlcnBsZXhpdHlDbGllbnQoCiAgICAgICAgICAgICAgICAgICAgYXBpX2tleT1DRkcucHBseF9hcGlfa2V5LAogICAgICAgICAgICAgICAgICAgIG1vZGVsPURFQ0tfR0VOX01PREVMLAogICAgICAgICAgICAgICAgKQogICAgICAgICAgICAgICAgX2RlY2tfZ2VuX3YzID0gRGVja0dlbmVyYXRvclYzKAogICAgICAgICAgICAgICAgICAgIHBwbHhfY2xpZW50PXBwbHhfY2xpZW50LAogICAgICAgICAgICAgICAgICAgIGRiX2Nvbm5fZmFjdG9yeT1fZ2V0X2RiX2Nvbm4sCiAgICAgICAgICAgICAgICAgICAgZW1iZWRkaW5nX2luZGV4PV9jb2FjaF9lbWJlZGRpbmdzLAogICAgICAgICAgICAgICAgKQogICAgICAgICAgICAgICAgcHJpbnQoZiIgIERlY2sgR2VuIFYzOiAgSW5pdGlhbGl6ZWQgKG1vZGVsOiB7REVDS19HRU5fTU9ERUx9KSIpCiAgICAgICAgICAgIGV4Y2VwdCBJbXBvcnRFcnJvciBhcyBlOgogICAgICAgICAgICAgICAgX2RlY2tfZ2VuX3YzX2Vycm9yID0gZiJNaXNzaW5nIGRlcGVuZGVuY3k6IHtlfS4gUnVuOiBwaXAgaW5zdGFsbCBvcGVuYWkiCiAgICAgICAgICAgICAgICBwcmludChmIiAgRGVjayBHZW4gVjM6ICB7X2RlY2tfZ2VuX3YzX2Vycm9yfSIpCiAgICAgICAgICAgICAgICBfZGVja19nZW5fdjMgPSBOb25lCiAgICAgICAgICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICAgICAgICAgIF9kZWNrX2dlbl92M19lcnJvciA9IHN0cihlKQogICAgICAgICAgICAgICAgcHJpbnQoZiIgIERlY2sgR2VuIFYzOiAgRmFpbGVkIHRvIGluaXRpYWxpemU6IHtlfSIpCiAgICAgICAgICAgICAgICBfZGVja19nZW5fdjMgPSBOb25lCiAgICAgICAgZWxzZToKICAgICAgICAgICAgX2RlY2tfZ2VuX3YzX2Vycm9yID0gIlBQTFhfQVBJX0tFWSBub3Qgc2V0IgogICAgICAgICAgICBwcmludCgiICBEZWNrIEdlbiBWMzogIFNraXBwZWQgKG5vIFBQTFhfQVBJX0tFWSkiKQoKICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICBwcmludChmIiAgQ29hY2g6ICAgICAgICBGYWlsZWQgdG8gaW5pdGlhbGl6ZToge2V9IikKICAgICAgICBfY29hY2hfc2VydmljZSA9IE5vbmUKCgpAYXBwLmdldCgiL2FwaS9jb2FjaC9zdGF0dXMiKQphc3luYyBkZWYgY29hY2hfc3RhdHVzKCk6CiAgICAiIiJDaGVjayBjb2FjaCBzdWJzeXN0ZW0gaGVhbHRoLiIiIgogICAgaWYgX2NvYWNoX3NlcnZpY2UgaXMgTm9uZToKICAgICAgICByZXR1cm4geyJsbG1Db25uZWN0ZWQiOiBGYWxzZSwgImVtYmVkZGluZ3NMb2FkZWQiOiBGYWxzZSwKICAgICAgICAgICAgICAgICJlbWJlZGRpbmdDYXJkcyI6IDAsICJkZWNrUmVwb3J0c0F2YWlsYWJsZSI6IDAsCiAgICAgICAgICAgICAgICAiZXJyb3IiOiAiQ29hY2ggc2VydmljZSBub3QgaW5pdGlhbGl6ZWQifQogICAgc3RhdHVzID0gX2NvYWNoX3NlcnZpY2UuZ2V0X3N0YXR1cygpCiAgICByZXR1cm4gc3RhdHVzLm1vZGVsX2R1bXAoKQoKCkBhcHAuZ2V0KCIvYXBpL2NvYWNoL2RlY2tzIikKYXN5bmMgZGVmIGNvYWNoX2xpc3RfZGVja3MoKToKICAgICIiIkxpc3QgYWxsIGRlY2tzIGF2YWlsYWJsZSBmb3IgY29hY2hpbmcgKGZyb20gZGVjayBi' +
'dWlsZGVyIERCKS4iIiIKICAgIGNvbm4gPSBfZ2V0X2RiX2Nvbm4oKQogICAgcm93cyA9IGNvbm4uZXhlY3V0ZSgKICAgICAgICAiIiJTRUxFQ1QgZC5pZCBhcyBkZWNrX2lkLCBkLm5hbWUgYXMgZGVja19uYW1lLCBkLmNvbW1hbmRlcl9uYW1lLAogICAgICAgICAgICAgICAgICBDT1VOVChkYy5pZCkgYXMgY2FyZF9jb3VudAogICAgICAgICAgIEZST00gZGVja3MgZAogICAgICAgICAgIExFRlQgSk9JTiBkZWNrX2NhcmRzIGRjIE9OIGRjLmRlY2tfaWQgPSBkLmlkCiAgICAgICAgICAgR1JPVVAgQlkgZC5pZAogICAgICAgICAgIE9SREVSIEJZIGQubmFtZSIiIgogICAgKS5mZXRjaGFsbCgpCgogICAgIyBBbHNvIGdldCByZXBvcnQgYXZhaWxhYmlsaXR5IGZyb20gY29hY2ggc2VydmljZQogICAgcmVwb3J0X2lkcyA9IHNldCgpCiAgICBpZiBfY29hY2hfc2VydmljZToKICAgICAgICByZXBvcnRfaWRzID0gc2V0KF9jb2FjaF9zZXJ2aWNlLmxpc3RfZGVja19yZXBvcnRzKCkpCgogICAgZGVja3MgPSBbXQogICAgZm9yIHIgaW4gcm93czoKICAgICAgICBkZWNrX25hbWUgPSByWyJkZWNrX25hbWUiXQogICAgICAgICMgQ2hlY2sgaWYgYSByZXBvcnQgZXhpc3RzIChieSBkZWNrIG5hbWUgc2x1ZyBtYXRjaCkKICAgICAgICBoYXNfcmVwb3J0ID0gYW55KAogICAgICAgICAgICBkZWNrX25hbWUubG93ZXIoKS5yZXBsYWNlKCIgIiwgIi0iKSA9PSByaWQubG93ZXIoKSBvcgogICAgICAgICAgICBkZWNrX25hbWUubG93ZXIoKSA9PSByaWQubG93ZXIoKQogICAgICAgICAgICBmb3IgcmlkIGluIHJlcG9ydF9pZHMKICAgICAgICApCiAgICAgICAgZGVja3MuYXBwZW5kKHsKICAgICAgICAgICAgImRlY2tfaWQiOiByWyJkZWNrX2lkIl0sCiAgICAgICAgICAgICJkZWNrX25hbWUiOiByWyJkZWNrX25hbWUiXSwKICAgICAgICAgICAgImNvbW1hbmRlciI6IHJbImNvbW1hbmRlcl9uYW1lIl0gb3IgIiIsCiAgICAgICAgICAgICJjYXJkX2NvdW50IjogclsiY2FyZF9jb3VudCJdLAogICAgICAgICAgICAiaGFzX3JlcG9ydCI6IGhhc19yZXBvcnQsCiAgICAgICAgICAgICJyZXBvcnRfY291bnQiOiAxIGlmIGhhc19yZXBvcnQgZWxzZSAwLAogICAgICAgICAgICAibGFzdF9yZXBvcnRfZGF0ZSI6IE5vbmUsCiAgICAgICAgfSkKICAgIHJldHVybiBkZWNrcwoKCkBhcHAuZ2V0KCIvYXBpL2NvYWNoL2RlY2tzL3tkZWNrX2lkfS9yZXBvcnQiKQphc3luYyBkZWYgY29hY2hfZ2V0X3JlcG9ydChkZWNrX2lkOiBzdHIpOgogICAgIiIiR2V0IHRoZSBsYXRlc3QgRGVja1JlcG9ydCBmb3IgYSBkZWNrLiIiIgogICAgaWYgX2NvYWNoX3NlcnZpY2UgaXMgTm9uZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMCwgIkNvYWNoIHNlcnZpY2Ugbm90IGluaXRpYWxpemVkIikKICAgIHJlcG9ydCA9IF9jb2FjaF9zZXJ2aWNlLmxvYWRfZGVja19yZXBvcnQoZGVja19pZCkKICAgIGlmIHJlcG9ydCBpcyBOb25lOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNDA0LCBmIkRlY2sgcmVwb3J0IG5vdCBmb3VuZDoge2RlY2tfaWR9IikKICAgIHJldHVybiByZXBvcnQubW9kZWxfZHVtcCgpCgoKY2xhc3MgQ29hY2hSZXF1ZXN0Qm9keShCYXNlTW9kZWwpOgogICAgZ29hbHM6IE9wdGlvbmFsW2RpY3RdID0gTm9uZQoKCmNsYXNzIENvYWNoQ2hhdE1lc3NhZ2UoQmFzZU1vZGVsKToKICAgIHJvbGU6IHN0ciAgIyAidXNlciIgb3IgImFzc2lzdGFudCIKICAgIGNvbnRlbnQ6IHN0cgoKY2xhc3MgQ29hY2hDaGF0UmVxdWVzdChCYXNlTW9kZWwpOgogICAgZGVja19pZDogc3RyCiAgICBtZXNzYWdlczogbGlzdFtkaWN0XSAgIyBjb252ZXJzYXRpb24gaGlzdG9yeSBbe3JvbGUsIGNvbnRlbnR9XQogICAgZ29hbHM6IE9wdGlvbmFsW2RpY3RdID0gTm9uZQogICAgc3RyZWFtOiBPcHRpb25hbFtib29sXSA9IEZhbHNlCgpjbGFzcyBDb2FjaEFwcGx5UmVxdWVzdChCYXNlTW9kZWwpOgogICAgc2Vzc2lvbl9pZDogc3RyCiAgICBkZWNrX2lkOiBpbnQgICMgbnVtZXJpYyBkZWNrIElEIGluIHRoZSBEQgogICAgYWNjZXB0ZWRfY3V0czogbGlzdFtzdHJdID0gW10gICMgY2FyZCBuYW1lcyB0byByZW1vdmUKICAgIGFjY2VwdGVkX2FkZHM6IGxpc3Rbc3RyXSA9IFtdICAjIGNhcmQgbmFtZXMgdG8gYWRkCgpjbGFzcyBDb2FjaEdvYWxzUmVxdWVzdChCYXNlTW9kZWwpOgogICAgdGFyZ2V0X3Bvd2VyX2xldmVsOiBPcHRpb25hbFtpbnRdID0gTm9uZSAgIyAxLTEwCiAgICBtZXRhX2ZvY3VzOiBP' +
'cHRpb25hbFtzdHJdID0gTm9uZSAgIyBhZ2dybywgY29udHJvbCwgY29tYm8sIG1pZHJhbmdlLCBzdGF4CiAgICBidWRnZXQ6IE9wdGlvbmFsW3N0cl0gPSBOb25lICAjIGJ1ZGdldCwgbWVkaXVtLCBuby1saW1pdAogICAgZm9jdXNfYXJlYXM6IGxpc3Rbc3RyXSA9IFtdICAjIGUuZy4sIFsicmFtcCIsICJjYXJkIGRyYXciXQoKCkBhcHAucG9zdCgiL2FwaS9jb2FjaC9kZWNrcy97ZGVja19pZH0iKQphc3luYyBkZWYgY29hY2hfcnVuX3Nlc3Npb24oZGVja19pZDogc3RyLCBib2R5OiBDb2FjaFJlcXVlc3RCb2R5ID0gTm9uZSk6CiAgICAiIiJUcmlnZ2VyIGEgY29hY2hpbmcgc2Vzc2lvbiBmb3IgYSBkZWNrLiIiIgogICAgaWYgX2NvYWNoX3NlcnZpY2UgaXMgTm9uZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMCwgIkNvYWNoIHNlcnZpY2Ugbm90IGluaXRpYWxpemVkIikKCiAgICAjIExvYWQgZW1iZWRkaW5ncyBvbiBmaXJzdCB1c2UgaWYgbm90IGxvYWRlZAogICAgaWYgX2NvYWNoX2VtYmVkZGluZ3MgYW5kIG5vdCBfY29hY2hfZW1iZWRkaW5ncy5sb2FkZWQ6CiAgICAgICAgdHJ5OgogICAgICAgICAgICBfY29hY2hfZW1iZWRkaW5ncy5sb2FkKGZvcmNlX2Rvd25sb2FkPVRydWUpCiAgICAgICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgICAgICBwcmludChmIiAgQ29hY2g6IEVtYmVkZGluZ3MgZG93bmxvYWQgZmFpbGVkOiB7ZX0iKQoKICAgIGdvYWxzID0gTm9uZQogICAgaWYgYm9keSBhbmQgYm9keS5nb2FsczoKICAgICAgICBmcm9tIGNvYWNoLm1vZGVscyBpbXBvcnQgQ29hY2hHb2FscwogICAgICAgIHRyeToKICAgICAgICAgICAgZ29hbHMgPSBDb2FjaEdvYWxzKCoqYm9keS5nb2FscykKICAgICAgICBleGNlcHQgRXhjZXB0aW9uOgogICAgICAgICAgICBnb2FscyA9IE5vbmUKCiAgICAjIEJ1aWxkIGEgZmFsbGJhY2sgRGVja1JlcG9ydCBmcm9tIHRoZSBEQiBpZiBubyBzaW11bGF0aW9uIHJlcG9ydCBleGlzdHMKICAgIGZhbGxiYWNrX3JlcG9ydCA9IE5vbmUKICAgIHRyeToKICAgICAgICBmYWxsYmFja19yZXBvcnQgPSBfYnVpbGRfZGVja19yZXBvcnRfZnJvbV9kYihkZWNrX2lkKQogICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgIHByaW50KGYiICBDb2FjaDogRmFsbGJhY2sgcmVwb3J0IGJ1aWxkIGZhaWxlZCBmb3IgJ3tkZWNrX2lkfSc6IHtlfSIpCgogICAgdHJ5OgogICAgICAgIHNlc3Npb24gPSBhd2FpdCBfY29hY2hfc2VydmljZS5ydW5fY29hY2hpbmdfc2Vzc2lvbihkZWNrX2lkLCBnb2FscywgZmFsbGJhY2tfcmVwb3J0PWZhbGxiYWNrX3JlcG9ydCkKICAgICAgICByZXR1cm4gc2Vzc2lvbi5tb2RlbF9kdW1wKCkKICAgIGV4Y2VwdCBWYWx1ZUVycm9yIGFzIGU6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig0MDQsIHN0cihlKSkKICAgIGV4Y2VwdCBDb25uZWN0aW9uRXJyb3IgYXMgZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMywgZiJMTE0gY29ubmVjdGlvbiBmYWlsZWQ6IHtlfSIpCiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig1MDAsIGYiQ29hY2ggc2Vzc2lvbiBmYWlsZWQ6IHtlfSIpCgoKZGVmIF9idWlsZF9kZWNrX3JlcG9ydF9mcm9tX2RiKGRlY2tfc2x1Zzogc3RyKToKICAgICIiIgogICAgQnVpbGQgYSBsaWdodHdlaWdodCBEZWNrUmVwb3J0IGZyb20gdGhlIGRlY2sgYnVpbGRlciBEQiB3aGVuIG5vCiAgICBzaW11bGF0aW9uIHJlcG9ydCBleGlzdHMuIEFsbG93cyB0aGUgY29hY2ggdG8gYW5hbHl6ZSBkZWNrIGNvbXBvc2l0aW9uCiAgICBldmVuIHdpdGhvdXQgc2ltdWxhdGlvbiBkYXRhLgogICAgIiIiCiAgICBmcm9tIGNvYWNoLm1vZGVscyBpbXBvcnQgRGVja1JlcG9ydCwgQ2FyZFBlcmZvcm1hbmNlLCBEZWNrU3RydWN0dXJlCiAgICBjb25uID0gX2dldF9kYl9jb25uKCkKCiAgICAjIEZpbmQgdGhlIGRlY2sgYnkgc2x1ZyBtYXRjaCBhZ2FpbnN0IHRoZSBkZWNrIG5hbWUKICAgIHJvd3MgPSBjb25uLmV4ZWN1dGUoCiAgICAgICAgIlNFTEVDVCBpZCwgbmFtZSwgY29tbWFuZGVyX25hbWUsIGNvbG9yX2lkZW50aXR5IEZST00gZGVja3MgT1JERVIgQlkgaWQiCiAgICApLmZldGNoYWxsKCkKCiAgICBtYXRjaGVkX2RlY2sgPSBOb25lCiAgICBmb3IgciBpbiByb3dzOgogICAgICAgIG5hbWUgPSByWyJuYW1lIl0gb3IgIiIKICAgICAgICBzbHVnID0gbmFtZS5sb3dlcigpLnJlcGxhY2UoIiAiLCAiLSIp' +
'CiAgICAgICAgIyBBbHNvIHRyeSBhIG1vcmUgdGhvcm91Z2ggc2x1Z2lmeQogICAgICAgIGltcG9ydCByZQogICAgICAgIGNsZWFuX3NsdWcgPSByZS5zdWIocidbXmEtejAtOV0rJywgJy0nLCBuYW1lLmxvd2VyKCkpLnN0cmlwKCctJykKICAgICAgICBpZiBzbHVnID09IGRlY2tfc2x1Zy5sb3dlcigpIG9yIGNsZWFuX3NsdWcgPT0gZGVja19zbHVnLmxvd2VyKCkgb3IgbmFtZS5sb3dlcigpID09IGRlY2tfc2x1Zy5sb3dlcigpOgogICAgICAgICAgICBtYXRjaGVkX2RlY2sgPSByCiAgICAgICAgICAgIGJyZWFrCgogICAgaWYgbWF0Y2hlZF9kZWNrIGlzIE5vbmU6CiAgICAgICAgcmV0dXJuIE5vbmUKCiAgICBkZWNrX2lkID0gbWF0Y2hlZF9kZWNrWyJpZCJdCiAgICBkZWNrX25hbWUgPSBtYXRjaGVkX2RlY2tbIm5hbWUiXQogICAgY29tbWFuZGVyID0gbWF0Y2hlZF9kZWNrWyJjb21tYW5kZXJfbmFtZSJdIG9yICIiCgogICAgIyBQYXJzZSBjb2xvciBpZGVudGl0eQogICAgY2lfcmF3ID0gbWF0Y2hlZF9kZWNrWyJjb2xvcl9pZGVudGl0eSJdIG9yICJbXSIKICAgIHRyeToKICAgICAgICBjb2xvcl9pZGVudGl0eSA9IGpzb24ubG9hZHMoY2lfcmF3KSBpZiBpc2luc3RhbmNlKGNpX3Jhdywgc3RyKSBlbHNlIGNpX3JhdwogICAgZXhjZXB0IEV4Y2VwdGlvbjoKICAgICAgICBjb2xvcl9pZGVudGl0eSA9IFtdCgogICAgIyBMb2FkIGFsbCBjYXJkcyBpbiB0aGlzIGRlY2sKICAgIGNhcmRfcm93cyA9IGNvbm4uZXhlY3V0ZSgKICAgICAgICAiIiJTRUxFQ1QgZGMuY2FyZF9uYW1lLCBkYy5xdWFudGl0eSwgZGMuaXNfY29tbWFuZGVyLCBkYy5yb2xlX3RhZywKICAgICAgICAgICAgICAgICAgY2UudHlwZV9saW5lLCBjZS5jbWMsIGNlLm9yYWNsZV90ZXh0CiAgICAgICAgICAgRlJPTSBkZWNrX2NhcmRzIGRjCiAgICAgICAgICAgTEVGVCBKT0lOIGNvbGxlY3Rpb25fZW50cmllcyBjZSBPTiBMT1dFUihkYy5jYXJkX25hbWUpID0gTE9XRVIoY2UubmFtZSkKICAgICAgICAgICBXSEVSRSBkYy5kZWNrX2lkID0gPyIiIiwKICAgICAgICAoZGVja19pZCwpCiAgICApLmZldGNoYWxsKCkKCiAgICBjYXJkcyA9IFtdCiAgICB0eXBlX2NvdW50cyA9IHt9CiAgICBjbWNfYnVja2V0cyA9IFswXSAqIDgKICAgIGxhbmRfY291bnQgPSAwCgogICAgZm9yIGNyIGluIGNhcmRfcm93czoKICAgICAgICBjYXJkX25hbWUgPSBjclsiY2FyZF9uYW1lIl0gb3IgIiIKICAgICAgICB0eXBlX2xpbmUgPSBjclsidHlwZV9saW5lIl0gb3IgIiIKICAgICAgICBjbWMgPSBjclsiY21jIl0gb3IgMAogICAgICAgIHF0eSA9IGNyWyJxdWFudGl0eSJdIG9yIDEKICAgICAgICByb2xlX3RhZyA9IGNyWyJyb2xlX3RhZyJdIG9yICIiCgogICAgICAgICMgQnVpbGQgdGFncyBmcm9tIHR5cGVfbGluZSBhbmQgcm9sZV90YWcKICAgICAgICB0YWdzID0gW10KICAgICAgICBpZiByb2xlX3RhZzoKICAgICAgICAgICAgdGFncy5hcHBlbmQocm9sZV90YWcpCiAgICAgICAgaWYgIkxhbmQiIGluIHR5cGVfbGluZToKICAgICAgICAgICAgdGFncy5hcHBlbmQoImxhbmQiKQogICAgICAgICAgICBsYW5kX2NvdW50ICs9IHF0eQogICAgICAgIGVsaWYgIkNyZWF0dXJlIiBpbiB0eXBlX2xpbmU6CiAgICAgICAgICAgIHRhZ3MuYXBwZW5kKCJjcmVhdHVyZSIpCiAgICAgICAgZWxpZiAiSW5zdGFudCIgaW4gdHlwZV9saW5lOgogICAgICAgICAgICB0YWdzLmFwcGVuZCgiaW5zdGFudCIpCiAgICAgICAgZWxpZiAiU29yY2VyeSIgaW4gdHlwZV9saW5lOgogICAgICAgICAgICB0YWdzLmFwcGVuZCgic29yY2VyeSIpCiAgICAgICAgZWxpZiAiQXJ0aWZhY3QiIGluIHR5cGVfbGluZToKICAgICAgICAgICAgdGFncy5hcHBlbmQoImFydGlmYWN0IikKICAgICAgICBlbGlmICJFbmNoYW50bWVudCIgaW4gdHlwZV9saW5lOgogICAgICAgICAgICB0YWdzLmFwcGVuZCgiZW5jaGFudG1lbnQiKQogICAgICAgIGVsaWYgIlBsYW5lc3dhbGtlciIgaW4gdHlwZV9saW5lOgogICAgICAgICAgICB0YWdzLmFwcGVuZCgicGxhbmVzd2Fsa2VyIikKCiAgICAgICAgIyBUeXBlIGNvdW50CiAgICAgICAgZm9yIHQgaW4gWyJDcmVhdHVyZSIsICJJbnN0YW50IiwgIlNvcmNlcnkiLCAiQXJ0aWZhY3QiLCAiRW5jaGFudG1lbnQiLCAiUGxhbmVzd2Fsa2VyIiwgIkxhbmQiXToKICAgICAgICAgICAgaWYgdCBpbiB0eXBlX2xpbmU6CiAgICAgICAgICAgICAgICB0eXBlX2NvdW50c1t0XSA9IHR5cGVfY291bnRzLmdldCh0LCAwKSArIHF0' +
'eQoKICAgICAgICAjIENNQyBidWNrZXQKICAgICAgICBidWNrZXQgPSBtaW4oaW50KGNtYyksIDcpCiAgICAgICAgY21jX2J1Y2tldHNbYnVja2V0XSArPSBxdHkKCiAgICAgICAgIyBDYXJkUGVyZm9ybWFuY2Ugd2l0aCB6ZXJvZWQtb3V0IHNpbSBzdGF0cwogICAgICAgIGNhcmRzLmFwcGVuZChDYXJkUGVyZm9ybWFuY2UoCiAgICAgICAgICAgIG5hbWU9Y2FyZF9uYW1lLAogICAgICAgICAgICBkcmF3blJhdGU9MC4wLAogICAgICAgICAgICBjYXN0UmF0ZT0wLjAsCiAgICAgICAgICAgIGltcGFjdFNjb3JlPTAuMCwKICAgICAgICAgICAgdGFncz10YWdzLAogICAgICAgICkpCgogICAgc2x1ZyA9IGRlY2tfc2x1ZwogICAgcmV0dXJuIERlY2tSZXBvcnQoCiAgICAgICAgZGVja0lkPXNsdWcsCiAgICAgICAgY29tbWFuZGVyPWNvbW1hbmRlciwKICAgICAgICBjb2xvcklkZW50aXR5PWNvbG9yX2lkZW50aXR5LAogICAgICAgIGNhcmRzPWNhcmRzLAogICAgICAgIHN0cnVjdHVyZT1EZWNrU3RydWN0dXJlKAogICAgICAgICAgICBsYW5kQ291bnQ9bGFuZF9jb3VudCwKICAgICAgICAgICAgY3VydmVCdWNrZXRzPWNtY19idWNrZXRzLAogICAgICAgICAgICBjYXJkVHlwZUNvdW50cz10eXBlX2NvdW50cywKICAgICAgICApLAogICAgKQoKCkBhcHAuZ2V0KCIvYXBpL2NvYWNoL3Nlc3Npb25zIikKYXN5bmMgZGVmIGNvYWNoX2xpc3Rfc2Vzc2lvbnMoZGVja19pZDogc3RyID0gTm9uZSk6CiAgICAiIiJMaXN0IGFsbCBjb2FjaGluZyBzZXNzaW9ucywgb3B0aW9uYWxseSBmaWx0ZXJlZCBieSBkZWNrLiIiIgogICAgaWYgX2NvYWNoX3NlcnZpY2UgaXMgTm9uZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMCwgIkNvYWNoIHNlcnZpY2Ugbm90IGluaXRpYWxpemVkIikKICAgIHJldHVybiB7InNlc3Npb25zIjogX2NvYWNoX3NlcnZpY2UubGlzdF9zZXNzaW9ucyhkZWNrX2lkKX0KCgpAYXBwLmdldCgiL2FwaS9jb2FjaC9zZXNzaW9ucy97c2Vzc2lvbl9pZH0iKQphc3luYyBkZWYgY29hY2hfZ2V0X3Nlc3Npb24oc2Vzc2lvbl9pZDogc3RyKToKICAgICIiIkdldCBhIHNwZWNpZmljIGNvYWNoaW5nIHNlc3Npb24uIiIiCiAgICBpZiBfY29hY2hfc2VydmljZSBpcyBOb25lOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNTAwLCAiQ29hY2ggc2VydmljZSBub3QgaW5pdGlhbGl6ZWQiKQogICAgc2Vzc2lvbiA9IF9jb2FjaF9zZXJ2aWNlLmxvYWRfc2Vzc2lvbihzZXNzaW9uX2lkKQogICAgaWYgc2Vzc2lvbiBpcyBOb25lOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNDA0LCBmIlNlc3Npb24gbm90IGZvdW5kOiB7c2Vzc2lvbl9pZH0iKQogICAgcmV0dXJuIHNlc3Npb24ubW9kZWxfZHVtcCgpCgoKQGFwcC5wb3N0KCIvYXBpL2NvYWNoL2VtYmVkZGluZ3MvZG93bmxvYWQiKQphc3luYyBkZWYgY29hY2hfZG93bmxvYWRfZW1iZWRkaW5ncygpOgogICAgIiIiRG93bmxvYWQgYW5kIGNvbnZlcnQgTVRHIGNhcmQgZW1iZWRkaW5ncyBmcm9tIEh1Z2dpbmdGYWNlLiIiIgogICAgaWYgX2NvYWNoX2VtYmVkZGluZ3MgaXMgTm9uZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMCwgIkNvYWNoIHNlcnZpY2Ugbm90IGluaXRpYWxpemVkIikKICAgIHRyeToKICAgICAgICBfY29hY2hfZW1iZWRkaW5ncy5sb2FkKGZvcmNlX2Rvd25sb2FkPVRydWUpCiAgICAgICAgcmV0dXJuIHsKICAgICAgICAgICAgInN1Y2Nlc3MiOiBUcnVlLAogICAgICAgICAgICAiY2FyZHMiOiBfY29hY2hfZW1iZWRkaW5ncy5jYXJkX2NvdW50LAogICAgICAgICAgICAibWVzc2FnZSI6IGYiTG9hZGVkIHtfY29hY2hfZW1iZWRkaW5ncy5jYXJkX2NvdW50fSBjYXJkIGVtYmVkZGluZ3MiCiAgICAgICAgfQogICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNTAwLCBmIkZhaWxlZCB0byBkb3dubG9hZCBlbWJlZGRpbmdzOiB7ZX0iKQoKCkBhcHAuZ2V0KCIvYXBpL2NvYWNoL2VtYmVkZGluZ3Mvc2VhcmNoIikKYXN5bmMgZGVmIGNvYWNoX3NlYXJjaF9zaW1pbGFyKGNhcmQ6IHN0ciwgY29sb3JzOiBzdHIgPSBOb25lLCB0b3BfbjogaW50ID0gMTApOgogICAgIiIiU2VhcmNoIGZvciBzaW1pbGFyIGNhcmRzIGJ5IG5hbWUuIiIiCiAgICBpZiBfY29hY2hfZW1iZWRkaW5ncyBpcyBOb25lIG9yIG5vdCBfY29hY2hfZW1iZWRkaW5ncy5sb2FkZWQ6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig1MDMsICJFbWJlZGRpbmdzIG5v' +
'dCBsb2FkZWQiKQogICAgY29sb3JfZmlsdGVyID0gbGlzdChjb2xvcnMudXBwZXIoKSkgaWYgY29sb3JzIGVsc2UgTm9uZQogICAgbWF0Y2hlcyA9IF9jb2FjaF9lbWJlZGRpbmdzLnNlYXJjaF9zaW1pbGFyKAogICAgICAgIHF1ZXJ5X2NhcmQ9Y2FyZCwgY29sb3JfZmlsdGVyPWNvbG9yX2ZpbHRlciwgdG9wX249dG9wX24KICAgICkKICAgIHJldHVybiB7InF1ZXJ5IjogY2FyZCwgIm1hdGNoZXMiOiBbbS50b19kaWN0KCkgZm9yIG0gaW4gbWF0Y2hlc119CgoKQGFwcC5wb3N0KCIvYXBpL2NvYWNoL2NoYXQiKQphc3luYyBkZWYgY29hY2hfY2hhdChib2R5OiBDb2FjaENoYXRSZXF1ZXN0KToKICAgICIiIk11bHRpLXR1cm4gY29hY2hpbmcgY2hhdC4gU2VuZHMgY29udmVyc2F0aW9uIGhpc3RvcnkgdG8gTExNIGFuZCByZXR1cm5zIHJlc3BvbnNlLiIiIgogICAgaWYgX2NvYWNoX3NlcnZpY2UgaXMgTm9uZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMCwgIkNvYWNoIHNlcnZpY2Ugbm90IGluaXRpYWxpemVkIikKCiAgICAjIExvYWQgZGVjayByZXBvcnQgZm9yIGNvbnRleHQKICAgIHJlcG9ydCA9IF9jb2FjaF9zZXJ2aWNlLmxvYWRfZGVja19yZXBvcnQoYm9keS5kZWNrX2lkKQoKICAgICMgQnVpbGQgc3lzdGVtIHByb21wdCB3aXRoIHJlcG9ydCBjb250ZXh0CiAgICBmcm9tIGNvYWNoLnByb21wdF90ZW1wbGF0ZSBpbXBvcnQgYnVpbGRfc3lzdGVtX3Byb21wdAogICAgZnJvbSBjb2FjaC5tb2RlbHMgaW1wb3J0IENvYWNoR29hbHMKCiAgICBnb2FscyA9IE5vbmUKICAgIGlmIGJvZHkuZ29hbHM6CiAgICAgICAgdHJ5OgogICAgICAgICAgICBnb2FscyA9IENvYWNoR29hbHMoKipib2R5LmdvYWxzKQogICAgICAgIGV4Y2VwdCBFeGNlcHRpb246CiAgICAgICAgICAgIGdvYWxzID0gTm9uZQoKICAgIHN5c3RlbV9wcm9tcHQgPSAiIgogICAgaWYgcmVwb3J0OgogICAgICAgIHN5c3RlbV9wcm9tcHQgPSBidWlsZF9zeXN0ZW1fcHJvbXB0KHJlcG9ydCwgZ29hbHMpCiAgICBlbHNlOgogICAgICAgIHN5c3RlbV9wcm9tcHQgPSAoCiAgICAgICAgICAgICJZb3UgYXJlIGFuIGV4cGVydCBNYWdpYzogVGhlIEdhdGhlcmluZyBDb21tYW5kZXIgZGVjayBjb2FjaC4gIgogICAgICAgICAgICAiQW5zd2VyIHF1ZXN0aW9ucyBhYm91dCBkZWNrIGJ1aWxkaW5nLCBzdHJhdGVneSwgY2FyZCBjaG9pY2VzLCBhbmQgZ2FtZSB0aGVvcnkuICIKICAgICAgICAgICAgIkJlIHNwZWNpZmljIGFuZCBhY3Rpb25hYmxlIGluIHlvdXIgYWR2aWNlLiIKICAgICAgICApCgogICAgIyBCdWlsZCBtZXNzYWdlcyBhcnJheSBmb3IgTExNCiAgICBtZXNzYWdlcyA9IFt7InJvbGUiOiAic3lzdGVtIiwgImNvbnRlbnQiOiBzeXN0ZW1fcHJvbXB0fV0KICAgIGZvciBtc2cgaW4gYm9keS5tZXNzYWdlczoKICAgICAgICBtZXNzYWdlcy5hcHBlbmQoeyJyb2xlIjogbXNnLmdldCgicm9sZSIsICJ1c2VyIiksICJjb250ZW50IjogbXNnLmdldCgiY29udGVudCIsICIiKX0pCgogICAgIyBDYWxsIExMTSB3aXRoIG11bHRpLXR1cm4gbWVzc2FnZXMKICAgIHRyeToKICAgICAgICBpbXBvcnQganNvbgogICAgICAgIGZyb20gdXJsbGliLnJlcXVlc3QgaW1wb3J0IHVybG9wZW4sIFJlcXVlc3QgYXMgVXJsUmVxdWVzdAogICAgICAgIGZyb20gY29hY2guY29uZmlnIGltcG9ydCBMTV9TVFVESU9fVVJMLCBMTV9TVFVESU9fVElNRU9VVAoKICAgICAgICBtb2RlbF9uYW1lID0gX2NvYWNoX2xsbS5fcmVzb2x2ZV9tb2RlbCgpCiAgICAgICAgbGxtX2JvZHkgPSB7CiAgICAgICAgICAgICJtb2RlbCI6IG1vZGVsX25hbWUsCiAgICAgICAgICAgICJtZXNzYWdlcyI6IG1lc3NhZ2VzLAogICAgICAgICAgICAidGVtcGVyYXR1cmUiOiAwLjcsCiAgICAgICAgICAgICJtYXhfdG9rZW5zIjogNDA5NiwKICAgICAgICB9CgogICAgICAgIGlmIGJvZHkuc3RyZWFtOgogICAgICAgICAgICAjIFNTRSBzdHJlYW1pbmcgcmVzcG9uc2UKICAgICAgICAgICAgbGxtX2JvZHlbInN0cmVhbSJdID0gVHJ1ZQoKICAgICAgICAgICAgYXN5bmMgZGVmIGdlbmVyYXRlKCk6CiAgICAgICAgICAgICAgICBpbXBvcnQgYXN5bmNpbwogICAgICAgICAgICAgICAgZGVmIF9zdHJlYW0oKToKICAgICAgICAgICAgICAgICAgICByZXEgPSBVcmxSZXF1ZXN0KAogICAgICAgICAgICAgICAgICAgICAgICBmIntfY29hY2hfbGxtLmJhc2VfdXJsfS9jaGF0L2NvbXBsZXRpb25zIiwKICAgICAgICAgICAgICAgICAgICAgICAgZGF0YT1q' +
'c29uLmR1bXBzKGxsbV9ib2R5KS5lbmNvZGUoInV0Zi04IiksCiAgICAgICAgICAgICAgICAgICAgICAgIGhlYWRlcnM9eyJDb250ZW50LVR5cGUiOiAiYXBwbGljYXRpb24vanNvbiJ9LAogICAgICAgICAgICAgICAgICAgICAgICBtZXRob2Q9IlBPU1QiLAogICAgICAgICAgICAgICAgICAgICkKICAgICAgICAgICAgICAgICAgICBpbXBvcnQgaHR0cC5jbGllbnQKICAgICAgICAgICAgICAgICAgICBpbXBvcnQgdXJsbGliLnBhcnNlCiAgICAgICAgICAgICAgICAgICAgcGFyc2VkID0gdXJsbGliLnBhcnNlLnVybHBhcnNlKGYie19jb2FjaF9sbG0uYmFzZV91cmx9L2NoYXQvY29tcGxldGlvbnMiKQogICAgICAgICAgICAgICAgICAgIGNvbm4gPSBodHRwLmNsaWVudC5IVFRQQ29ubmVjdGlvbihwYXJzZWQuaG9zdG5hbWUsIHBhcnNlZC5wb3J0LCB0aW1lb3V0PUxNX1NUVURJT19USU1FT1VUKQogICAgICAgICAgICAgICAgICAgIGNvbm4ucmVxdWVzdCgiUE9TVCIsIHBhcnNlZC5wYXRoLCBib2R5PWpzb24uZHVtcHMobGxtX2JvZHkpLmVuY29kZSgidXRmLTgiKSwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgaGVhZGVycz17IkNvbnRlbnQtVHlwZSI6ICJhcHBsaWNhdGlvbi9qc29uIn0pCiAgICAgICAgICAgICAgICAgICAgcmVzcCA9IGNvbm4uZ2V0cmVzcG9uc2UoKQogICAgICAgICAgICAgICAgICAgIGNodW5rcyA9IFtdCiAgICAgICAgICAgICAgICAgICAgd2hpbGUgVHJ1ZToKICAgICAgICAgICAgICAgICAgICAgICAgbGluZSA9IHJlc3AucmVhZGxpbmUoKQogICAgICAgICAgICAgICAgICAgICAgICBpZiBub3QgbGluZToKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGJyZWFrCiAgICAgICAgICAgICAgICAgICAgICAgIGxpbmUgPSBsaW5lLmRlY29kZSgidXRmLTgiKS5zdHJpcCgpCiAgICAgICAgICAgICAgICAgICAgICAgIGlmIGxpbmUuc3RhcnRzd2l0aCgiZGF0YTogIik6CiAgICAgICAgICAgICAgICAgICAgICAgICAgICBkYXRhID0gbGluZVs2Ol0KICAgICAgICAgICAgICAgICAgICAgICAgICAgIGlmIGRhdGEgPT0gIltET05FXSI6CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgY2h1bmtzLmFwcGVuZCgiW0RPTkVdIikKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBicmVhawogICAgICAgICAgICAgICAgICAgICAgICAgICAgY2h1bmtzLmFwcGVuZChkYXRhKQogICAgICAgICAgICAgICAgICAgIGNvbm4uY2xvc2UoKQogICAgICAgICAgICAgICAgICAgIHJldHVybiBjaHVua3MKCiAgICAgICAgICAgICAgICBsb29wID0gYXN5bmNpby5nZXRfZXZlbnRfbG9vcCgpCiAgICAgICAgICAgICAgICBjaHVua3MgPSBhd2FpdCBsb29wLnJ1bl9pbl9leGVjdXRvcihOb25lLCBfc3RyZWFtKQoKICAgICAgICAgICAgICAgIGZvciBjaHVua19zdHIgaW4gY2h1bmtzOgogICAgICAgICAgICAgICAgICAgIGlmIGNodW5rX3N0ciA9PSAiW0RPTkVdIjoKICAgICAgICAgICAgICAgICAgICAgICAgeWllbGQgZiJkYXRhOiBbRE9ORV1cblxuIgogICAgICAgICAgICAgICAgICAgICAgICBicmVhawogICAgICAgICAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgICAgICAgICAgY2h1bmsgPSBqc29uLmxvYWRzKGNodW5rX3N0cikKICAgICAgICAgICAgICAgICAgICAgICAgZGVsdGEgPSBjaHVuay5nZXQoImNob2ljZXMiLCBbe31dKVswXS5nZXQoImRlbHRhIiwge30pCiAgICAgICAgICAgICAgICAgICAgICAgIGNvbnRlbnQgPSBkZWx0YS5nZXQoImNvbnRlbnQiLCAiIikKICAgICAgICAgICAgICAgICAgICAgICAgaWYgY29udGVudDoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICMgU3RyaXAgdGhpbmsgdGFncyBmcm9tIHN0cmVhbWluZyBjaHVua3MKICAgICAgICAgICAgICAgICAgICAgICAgICAgIHlpZWxkIGYiZGF0YToge2pzb24uZHVtcHMoeydjb250ZW50JzogY29udGVudH0pfVxuXG4iCiAgICAgICAgICAgICAgICAgICAgZXhjZXB0IGpzb24uSlNPTkRlY29kZUVycm9yOgogICAgICAgICAgICAgICAgICAgICAgICBjb250aW51ZQoKICAgICAgICAgICAgcmV0dXJuIFN0cmVhbWluZ1Jlc3BvbnNlKGdlbmVyYXRlKCksIG1lZGlhX3R5cGU9InRleHQvZXZlbnQtc3RyZWFtIikKICAgICAgICBlbHNlOgogICAgICAgICAgICAjIE5vbi1zdHJlYW1pbmc6IHJlZ3VsYXIgY2FsbAogICAgICAgICAgICByZXEgPSBVcmxSZXF1ZXN0KAogICAgICAgICAgICAgICAgZiJ7X2NvYWNoX2xsbS5iYXNlX3VybH0v' +
'Y2hhdC9jb21wbGV0aW9ucyIsCiAgICAgICAgICAgICAgICBkYXRhPWpzb24uZHVtcHMobGxtX2JvZHkpLmVuY29kZSgidXRmLTgiKSwKICAgICAgICAgICAgICAgIGhlYWRlcnM9eyJDb250ZW50LVR5cGUiOiAiYXBwbGljYXRpb24vanNvbiJ9LAogICAgICAgICAgICAgICAgbWV0aG9kPSJQT1NUIiwKICAgICAgICAgICAgKQoKICAgICAgICAgICAgaW1wb3J0IGFzeW5jaW8KICAgICAgICAgICAgbG9vcCA9IGFzeW5jaW8uZ2V0X2V2ZW50X2xvb3AoKQogICAgICAgICAgICBkZWYgX2NhbGwoKToKICAgICAgICAgICAgICAgIHdpdGggdXJsb3BlbihyZXEsIHRpbWVvdXQ9TE1fU1RVRElPX1RJTUVPVVQpIGFzIHJlc3A6CiAgICAgICAgICAgICAgICAgICAgcmV0dXJuIGpzb24ubG9hZHMocmVzcC5yZWFkKCkuZGVjb2RlKCJ1dGYtOCIpKQoKICAgICAgICAgICAgcmF3ID0gYXdhaXQgbG9vcC5ydW5faW5fZXhlY3V0b3IoTm9uZSwgX2NhbGwpCgogICAgICAgICAgICBjb250ZW50ID0gcmF3LmdldCgiY2hvaWNlcyIsIFt7fV0pWzBdLmdldCgibWVzc2FnZSIsIHt9KS5nZXQoImNvbnRlbnQiLCAiIikKICAgICAgICAgICAgIyBTdHJpcCB0aGluayB0YWdzCiAgICAgICAgICAgIGNvbnRlbnQgPSBfY29hY2hfbGxtLl9zdHJpcF90aGlua190YWdzKGNvbnRlbnQpCiAgICAgICAgICAgIHVzYWdlID0gcmF3LmdldCgidXNhZ2UiLCB7fSkKCiAgICAgICAgICAgIHJldHVybiB7CiAgICAgICAgICAgICAgICAiY29udGVudCI6IGNvbnRlbnQsCiAgICAgICAgICAgICAgICAibW9kZWwiOiByYXcuZ2V0KCJtb2RlbCIsICIiKSwKICAgICAgICAgICAgICAgICJwcm9tcHRfdG9rZW5zIjogdXNhZ2UuZ2V0KCJwcm9tcHRfdG9rZW5zIiwgMCksCiAgICAgICAgICAgICAgICAiY29tcGxldGlvbl90b2tlbnMiOiB1c2FnZS5nZXQoImNvbXBsZXRpb25fdG9rZW5zIiwgMCksCiAgICAgICAgICAgIH0KCiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig1MDAsIGYiQ2hhdCBmYWlsZWQ6IHtlfSIpCgoKQGFwcC5wb3N0KCIvYXBpL2NvYWNoL2FwcGx5IikKYXN5bmMgZGVmIGNvYWNoX2FwcGx5X3N1Z2dlc3Rpb25zKGJvZHk6IENvYWNoQXBwbHlSZXF1ZXN0KToKICAgICIiIkFwcGx5IGFjY2VwdGVkIGNvYWNoaW5nIHN1Z2dlc3Rpb25zIHRvIGEgZGVjayDigJQgcmVtb3ZlIGN1dHMsIGFkZCBhZGRzLiIiIgogICAgY29ubiA9IF9nZXRfZGJfY29ubigpCgogICAgIyBWZXJpZnkgZGVjayBleGlzdHMKICAgIGRlY2sgPSBjb25uLmV4ZWN1dGUoIlNFTEVDVCAqIEZST00gZGVja3MgV0hFUkUgaWQgPSA/IiwgKGJvZHkuZGVja19pZCwpKS5mZXRjaG9uZSgpCiAgICBpZiBub3QgZGVjazoKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDQwNCwgZiJEZWNrIHtib2R5LmRlY2tfaWR9IG5vdCBmb3VuZCIpCgogICAgcmVzdWx0cyA9IHsiY3V0cyI6IFtdLCAiYWRkcyI6IFtdLCAiZXJyb3JzIjogW119CgogICAgIyBQcm9jZXNzIGN1dHM6IHJlbW92ZSBjYXJkcyBieSBuYW1lCiAgICBmb3IgY2FyZF9uYW1lIGluIGJvZHkuYWNjZXB0ZWRfY3V0czoKICAgICAgICByb3cgPSBjb25uLmV4ZWN1dGUoCiAgICAgICAgICAgICJTRUxFQ1QgaWQgRlJPTSBkZWNrX2NhcmRzIFdIRVJFIGRlY2tfaWQgPSA/IEFORCBjYXJkX25hbWUgPSA/IExJTUlUIDEiLAogICAgICAgICAgICAoYm9keS5kZWNrX2lkLCBjYXJkX25hbWUpCiAgICAgICAgKS5mZXRjaG9uZSgpCiAgICAgICAgaWYgcm93OgogICAgICAgICAgICBjb25uLmV4ZWN1dGUoIkRFTEVURSBGUk9NIGRlY2tfY2FyZHMgV0hFUkUgaWQgPSA/IiwgKHJvd1siaWQiXSwpKQogICAgICAgICAgICByZXN1bHRzWyJjdXRzIl0uYXBwZW5kKHsibmFtZSI6IGNhcmRfbmFtZSwgInN0YXR1cyI6ICJyZW1vdmVkIn0pCiAgICAgICAgZWxzZToKICAgICAgICAgICAgIyBUcnkgY2FzZS1pbnNlbnNpdGl2ZQogICAgICAgICAgICByb3cgPSBjb25uLmV4ZWN1dGUoCiAgICAgICAgICAgICAgICAiU0VMRUNUIGlkLCBjYXJkX25hbWUgRlJPTSBkZWNrX2NhcmRzIFdIRVJFIGRlY2tfaWQgPSA/IEFORCBMT1dFUihjYXJkX25hbWUpID0gTE9XRVIoPykgTElNSVQgMSIsCiAgICAgICAgICAgICAgICAoYm9keS5kZWNrX2lkLCBjYXJkX25hbWUpCiAgICAgICAgICAgICkuZmV0Y2hvbmUoKQogICAgICAgICAgICBpZiByb3c6CiAgICAgICAgICAgICAgICBjb25uLmV4ZWN1dGUoIkRFTEVURSBGUk9NIGRlY2tfY2FyZHMg' +
'V0hFUkUgaWQgPSA/IiwgKHJvd1siaWQiXSwpKQogICAgICAgICAgICAgICAgcmVzdWx0c1siY3V0cyJdLmFwcGVuZCh7Im5hbWUiOiByb3dbImNhcmRfbmFtZSJdLCAic3RhdHVzIjogInJlbW92ZWQifSkKICAgICAgICAgICAgZWxzZToKICAgICAgICAgICAgICAgIHJlc3VsdHNbImVycm9ycyJdLmFwcGVuZCh7Im5hbWUiOiBjYXJkX25hbWUsICJlcnJvciI6ICJDYXJkIG5vdCBmb3VuZCBpbiBkZWNrIn0pCgogICAgIyBQcm9jZXNzIGFkZHM6IGxvb2sgdXAgaW4gY29sbGVjdGlvbiBmaXJzdCwgdGhlbiBTY3J5ZmFsbAogICAgZm9yIGNhcmRfbmFtZSBpbiBib2R5LmFjY2VwdGVkX2FkZHM6CiAgICAgICAgIyBUcnkgdG8gZmluZCBpbiBjb2xsZWN0aW9uCiAgICAgICAgY2UgPSBjb25uLmV4ZWN1dGUoCiAgICAgICAgICAgICJTRUxFQ1Qgc2NyeWZhbGxfaWQsIG5hbWUgRlJPTSBjb2xsZWN0aW9uX2VudHJpZXMgV0hFUkUgTE9XRVIobmFtZSkgPSBMT1dFUig/KSBMSU1JVCAxIiwKICAgICAgICAgICAgKGNhcmRfbmFtZSwpCiAgICAgICAgKS5mZXRjaG9uZSgpCgogICAgICAgIHNjcnlmYWxsX2lkID0gTm9uZQogICAgICAgIHJlc29sdmVkX25hbWUgPSBjYXJkX25hbWUKCiAgICAgICAgaWYgY2U6CiAgICAgICAgICAgIHNjcnlmYWxsX2lkID0gY2VbInNjcnlmYWxsX2lkIl0KICAgICAgICAgICAgcmVzb2x2ZWRfbmFtZSA9IGNlWyJuYW1lIl0KICAgICAgICBlbHNlOgogICAgICAgICAgICAjIFRyeSBTY3J5ZmFsbCBBUEkgbG9va3VwCiAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgIGltcG9ydCB1cmxsaWIucGFyc2UKICAgICAgICAgICAgICAgIGVuY29kZWQgPSB1cmxsaWIucGFyc2UucXVvdGUoY2FyZF9uYW1lKQogICAgICAgICAgICAgICAgc2NyeV9yZXEgPSBVcmxSZXF1ZXN0KAogICAgICAgICAgICAgICAgICAgIGYiaHR0cHM6Ly9hcGkuc2NyeWZhbGwuY29tL2NhcmRzL25hbWVkP2Z1enp5PXtlbmNvZGVkfSIsCiAgICAgICAgICAgICAgICAgICAgaGVhZGVycz17IlVzZXItQWdlbnQiOiAiY29tbWFuZGVyLWFpLWxhYi8xLjAifQogICAgICAgICAgICAgICAgKQogICAgICAgICAgICAgICAgd2l0aCB1cmxvcGVuKHNjcnlfcmVxLCB0aW1lb3V0PTEwKSBhcyByZXNwOgogICAgICAgICAgICAgICAgICAgIGNhcmRfZGF0YSA9IGpzb24ubG9hZHMocmVzcC5yZWFkKCkuZGVjb2RlKCJ1dGYtOCIpKQogICAgICAgICAgICAgICAgICAgIHNjcnlmYWxsX2lkID0gY2FyZF9kYXRhLmdldCgiaWQiKQogICAgICAgICAgICAgICAgICAgIHJlc29sdmVkX25hbWUgPSBjYXJkX2RhdGEuZ2V0KCJuYW1lIiwgY2FyZF9uYW1lKQogICAgICAgICAgICBleGNlcHQgRXhjZXB0aW9uOgogICAgICAgICAgICAgICAgcGFzcwoKICAgICAgICBpZiBzY3J5ZmFsbF9pZDoKICAgICAgICAgICAgIyBDaGVjayBpZiBhbHJlYWR5IGluIGRlY2sKICAgICAgICAgICAgZXhpc3RpbmcgPSBjb25uLmV4ZWN1dGUoCiAgICAgICAgICAgICAgICAiU0VMRUNUIGlkIEZST00gZGVja19jYXJkcyBXSEVSRSBkZWNrX2lkID0gPyBBTkQgc2NyeWZhbGxfaWQgPSA/IiwKICAgICAgICAgICAgICAgIChib2R5LmRlY2tfaWQsIHNjcnlmYWxsX2lkKQogICAgICAgICAgICApLmZldGNob25lKCkKICAgICAgICAgICAgaWYgbm90IGV4aXN0aW5nOgogICAgICAgICAgICAgICAgY29ubi5leGVjdXRlKAogICAgICAgICAgICAgICAgICAgICJJTlNFUlQgSU5UTyBkZWNrX2NhcmRzIChkZWNrX2lkLCBzY3J5ZmFsbF9pZCwgY2FyZF9uYW1lLCBxdWFudGl0eSkgVkFMVUVTICg/LCA/LCA/LCAxKSIsCiAgICAgICAgICAgICAgICAgICAgKGJvZHkuZGVja19pZCwgc2NyeWZhbGxfaWQsIHJlc29sdmVkX25hbWUpCiAgICAgICAgICAgICAgICApCiAgICAgICAgICAgICAgICByZXN1bHRzWyJhZGRzIl0uYXBwZW5kKHsibmFtZSI6IHJlc29sdmVkX25hbWUsICJzY3J5ZmFsbF9pZCI6IHNjcnlmYWxsX2lkLCAic3RhdHVzIjogImFkZGVkIn0pCiAgICAgICAgICAgIGVsc2U6CiAgICAgICAgICAgICAgICByZXN1bHRzWyJhZGRzIl0uYXBwZW5kKHsibmFtZSI6IHJlc29sdmVkX25hbWUsICJzdGF0dXMiOiAiYWxyZWFkeV9pbl9kZWNrIn0pCiAgICAgICAgZWxzZToKICAgICAgICAgICAgcmVzdWx0c1siZXJyb3JzIl0uYXBwZW5kKHsibmFtZSI6IGNhcmRfbmFtZSwgImVycm9yIjogIkNvdWxkIG5vdCByZXNvbHZlIGNhcmQifSkKCiAgICBjb25uLmV4ZWN1dGUoIlVQREFURSBkZWNrcyBTRVQgdXBkYXRl' +
'ZF9hdCA9IGRhdGV0aW1lKCdub3cnKSBXSEVSRSBpZCA9ID8iLCAoYm9keS5kZWNrX2lkLCkpCiAgICBjb25uLmNvbW1pdCgpCgogICAgcmVzdWx0c1sidG90YWxfY3V0cyJdID0gbGVuKHJlc3VsdHNbImN1dHMiXSkKICAgIHJlc3VsdHNbInRvdGFsX2FkZHMiXSA9IGxlbihbYSBmb3IgYSBpbiByZXN1bHRzWyJhZGRzIl0gaWYgYVsic3RhdHVzIl0gPT0gImFkZGVkIl0pCiAgICByZXR1cm4gcmVzdWx0cwoKCkBhcHAuZ2V0KCIvYXBpL2NvYWNoL2NhcmRzLWxpa2UiKQphc3luYyBkZWYgY29hY2hfY2FyZHNfbGlrZShjYXJkOiBzdHIsIGNvbG9yczogc3RyID0gTm9uZSwgdG9wX246IGludCA9IDEwKToKICAgICIiIkZpbmQgY2FyZHMgc2ltaWxhciB0byBhIGdpdmVuIGNhcmQgdXNpbmcgZW1iZWRkaW5ncy4gVUktZnJpZW5kbHkgdmVyc2lvbi4iIiIKICAgIGlmIF9jb2FjaF9lbWJlZGRpbmdzIGlzIE5vbmUgb3Igbm90IF9jb2FjaF9lbWJlZGRpbmdzLmxvYWRlZDoKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMywgIkVtYmVkZGluZ3Mgbm90IGxvYWRlZCIpCgogICAgY29sb3JfZmlsdGVyID0gbGlzdChjb2xvcnMudXBwZXIoKSkgaWYgY29sb3JzIGVsc2UgTm9uZQogICAgbWF0Y2hlcyA9IF9jb2FjaF9lbWJlZGRpbmdzLnNlYXJjaF9zaW1pbGFyKAogICAgICAgIHF1ZXJ5X2NhcmQ9Y2FyZCwgY29sb3JfZmlsdGVyPWNvbG9yX2ZpbHRlciwgdG9wX249dG9wX24KICAgICkKCiAgICBjb25uID0gX2dldF9kYl9jb25uKCkKICAgIHJlc3VsdHMgPSBbXQogICAgZm9yIG0gaW4gbWF0Y2hlczoKICAgICAgICAjIENoZWNrIGlmIGNhcmQgaXMgaW4gY29sbGVjdGlvbgogICAgICAgIG93bmVkID0gY29ubi5leGVjdXRlKAogICAgICAgICAgICAiU0VMRUNUIFNVTShxdWFudGl0eSkgYXMgcXR5IEZST00gY29sbGVjdGlvbl9lbnRyaWVzIFdIRVJFIExPV0VSKG5hbWUpID0gTE9XRVIoPykiLAogICAgICAgICAgICAobS5uYW1lLCkKICAgICAgICApLmZldGNob25lKCkKICAgICAgICBvd25lZF9xdHkgPSAob3duZWRbInF0eSJdIG9yIDApIGlmIG93bmVkIGVsc2UgMAoKICAgICAgICAjIEdldCBpbWFnZSBVUkwgYW5kIHByaWNlCiAgICAgICAgY2UgPSBjb25uLmV4ZWN1dGUoCiAgICAgICAgICAgICJTRUxFQ1QgaW1hZ2VfdXJsLCB0Y2dfcHJpY2UgRlJPTSBjb2xsZWN0aW9uX2VudHJpZXMgV0hFUkUgTE9XRVIobmFtZSkgPSBMT1dFUig/KSBMSU1JVCAxIiwKICAgICAgICAgICAgKG0ubmFtZSwpCiAgICAgICAgKS5mZXRjaG9uZSgpCgogICAgICAgIHJlc3VsdHMuYXBwZW5kKHsKICAgICAgICAgICAgIm5hbWUiOiBtLm5hbWUsCiAgICAgICAgICAgICJzaW1pbGFyaXR5Ijogcm91bmQobS5zaW1pbGFyaXR5LCA0KSwKICAgICAgICAgICAgInR5cGVzIjogbS50eXBlcywKICAgICAgICAgICAgIm1hbmFfdmFsdWUiOiBtLm1hbmFfdmFsdWUsCiAgICAgICAgICAgICJtYW5hX2Nvc3QiOiBtLm1hbmFfY29zdCwKICAgICAgICAgICAgInRleHQiOiBtLnRleHRbOjIwMF0gaWYgbS50ZXh0IGVsc2UgIiIsCiAgICAgICAgICAgICJvd25lZF9xdHkiOiBvd25lZF9xdHksCiAgICAgICAgICAgICJpbWFnZV91cmwiOiBjZVsiaW1hZ2VfdXJsIl0gaWYgY2UgZWxzZSBOb25lLAogICAgICAgICAgICAidGNnX3ByaWNlIjogY2VbInRjZ19wcmljZSJdIGlmIGNlIGVsc2UgTm9uZSwKICAgICAgICB9KQoKICAgIHJldHVybiB7InF1ZXJ5IjogY2FyZCwgInJlc3VsdHMiOiByZXN1bHRzfQoKCkBhcHAucG9zdCgiL2FwaS9jb2FjaC9yZXBvcnRzL2dlbmVyYXRlIikKYXN5bmMgZGVmIGNvYWNoX2dlbmVyYXRlX3JlcG9ydHMoKToKICAgICIiIlJlYnVpbGQgYWxsIGRlY2sgcmVwb3J0cyBmcm9tIGJhdGNoIHJlc3VsdCBKU09OcyBpbiByZXN1bHRzLy4iIiIKICAgIHRyeToKICAgICAgICBmcm9tIGNvYWNoLnJlcG9ydF9nZW5lcmF0b3IgaW1wb3J0IGdlbmVyYXRlX2RlY2tfcmVwb3J0cwogICAgICAgIGxhYl9yb290ID0gUGF0aChfX2ZpbGVfXykucGFyZW50CiAgICAgICAgcmVzdWx0c19kaXIgPSBzdHIobGFiX3Jvb3QgLyBDRkcucmVzdWx0c19kaXIpCiAgICAgICAgcmVwb3J0c19kaXIgPSBzdHIobGFiX3Jvb3QgLyAiZGVjay1yZXBvcnRzIikKICAgICAgICB1cGRhdGVkID0gZ2VuZXJhdGVfZGVja19yZXBvcnRzKHJlc3VsdHNfZGlyLCByZXBvcnRzX2RpcikKICAgICAgICByZXR1cm4gewogICAgICAgICAgICAic3RhdHVzIjogIm9rIiwKICAgICAgICAgICAgImRlY2tzVXBk' +
'YXRlZCI6IHVwZGF0ZWQsCiAgICAgICAgICAgICJjb3VudCI6IGxlbih1cGRhdGVkKSwKICAgICAgICAgICAgIm1lc3NhZ2UiOiBmIkdlbmVyYXRlZCByZXBvcnRzIGZvciB7bGVuKHVwZGF0ZWQpfSBkZWNrcyIgaWYgdXBkYXRlZCBlbHNlICJObyBiYXRjaCByZXN1bHRzIGZvdW5kIiwKICAgICAgICB9CiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig1MDAsIGYiUmVwb3J0IGdlbmVyYXRpb24gZmFpbGVkOiB7ZX0iKQoKCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCiMgTUwgVHJhaW5pbmcgRGF0YSBFbmRwb2ludHMKIyDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZAKCkBhcHAuZ2V0KCIvYXBpL21sL3N0YXR1cyIpCmFzeW5jIGRlZiBtbF9zdGF0dXMoKToKICAgICIiIkdldCBNTCBkZWNpc2lvbiBsb2dnaW5nIHN0YXR1cyBhbmQgYXZhaWxhYmxlIHRyYWluaW5nIGRhdGEuIiIiCiAgICBnbG9iYWwgX21sX2xvZ2dpbmdfZW5hYmxlZAogICAgbGFiX3Jvb3QgPSBQYXRoKF9fZmlsZV9fKS5wYXJlbnQKICAgIHJlc3VsdHNfZGlyID0gbGFiX3Jvb3QgLyBDRkcucmVzdWx0c19kaXIKCiAgICAjIEZpbmQgZXhpc3RpbmcgTUwgZGVjaXNpb24gZmlsZXMKICAgIG1sX2ZpbGVzID0gW10KICAgIGlmIHJlc3VsdHNfZGlyLmV4aXN0cygpOgogICAgICAgIGZvciBmIGluIHNvcnRlZChyZXN1bHRzX2Rpci5nbG9iKCJtbC1kZWNpc2lvbnMtKi5qc29ubCIpKToKICAgICAgICAgICAgbGluZXMgPSAwCiAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgIHdpdGggb3BlbihmKSBhcyBmaDoKICAgICAgICAgICAgICAgICAgICBsaW5lcyA9IHN1bSgxIGZvciBfIGluIGZoKQogICAgICAgICAgICBleGNlcHQgRXhjZXB0aW9uOgogICAgICAgICAgICAgICAgcGFzcwogICAgICAgICAgICBtbF9maWxlcy5hcHBlbmQoewogICAgICAgICAgICAgICAgImZpbGUiOiBmLm5hbWUsCiAgICAgICAgICAgICAgICAiZGVjaXNpb25zIjogbGluZXMsCiAgICAgICAgICAgICAgICAic2l6ZV9rYiI6IHJvdW5kKGYuc3RhdCgpLnN0X3NpemUgLyAxMDI0LCAxKSwKICAgICAgICAgICAgfSkKCiAgICB0b3RhbF9kZWNpc2lvbnMgPSBzdW0oZlsiZGVjaXNpb25zIl0gZm9yIGYgaW4gbWxfZmlsZXMpCiAgICByZXR1cm4gewogICAgICAgICJtbF9sb2dnaW5nX2VuYWJsZWQiOiBfbWxfbG9nZ2luZ19lbmFibGVkLAogICAgICAgICJ0cmFpbmluZ19maWxlcyI6IG1sX2ZpbGVzLAogICAgICAgICJ0b3RhbF9kZWNpc2lvbnMiOiB0b3RhbF9kZWNpc2lvbnMsCiAgICAgICAgInRvdGFsX2ZpbGVzIjogbGVuKG1sX2ZpbGVzKSwKICAgIH0KCgpAYXBwLnBvc3QoIi9hcGkvbWwvdG9nZ2xlIikKYXN5bmMgZGVmIG1sX3RvZ2dsZShlbmFibGU6IGJvb2wgPSBUcnVlKToKICAgICIiIkVuYWJsZSBvciBkaXNhYmxlIE1MIGRlY2lzaW9uIGxvZ2dpbmcgZm9yIGZ1dHVyZSBiYXRjaCBydW5zLiIiIgogICAgZ2xvYmFsIF9tbF9sb2dnaW5nX2VuYWJsZWQKICAgIF9tbF9sb2dnaW5nX2VuYWJsZWQgPSBlbmFibGUKICAgIHJldHVybiB7CiAgICAgICAgIm1sX2xvZ2dpbmdfZW5hYmxlZCI6IF9tbF9sb2dnaW5nX2VuYWJsZWQsCiAgICAgICAgIm1lc3NhZ2UiOiBmIk1MIGRlY2lzaW9uIGxvZ2dpbmcgeydlbmFibGVkJyBpZiBlbmFibGUgZWxzZSAnZGlzYWJsZWQnfSBmb3IgZnV0dXJlIGJhdGNoZXMiLAogICAgfQoKCkBhcHAuZ2V0KCIvYXBpL21sL2RlY2lzaW9ucy97ZmlsZW5hbWV9IikKYXN5bmMgZGVmIG1sX2dldF9kZWNpc2lvbnMoZmlsZW5hbWU6IHN0ciwgbGltaXQ6IGludCA9IDEwMCwgb2Zmc2V0OiBpbnQgPSAwKToKICAgICIiIlJlYWQgZGVjaXNpb24gc25hcHNob3RzIGZyb20gYSB0cmFpbmluZyBkYXRhIGZpbGUuIiIiCiAgICBsYWJfcm9vdCA9IFBhdGgoX19maWxlX18pLnBh' +
'cmVudAogICAgZmlsZXBhdGggPSBsYWJfcm9vdCAvIENGRy5yZXN1bHRzX2RpciAvIGZpbGVuYW1lCgogICAgaWYgbm90IGZpbGVwYXRoLmV4aXN0cygpIG9yIG5vdCBmaWxlbmFtZS5zdGFydHN3aXRoKCJtbC1kZWNpc2lvbnMtIik6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig0MDQsIGYiTUwgZGVjaXNpb25zIGZpbGUgbm90IGZvdW5kOiB7ZmlsZW5hbWV9IikKCiAgICBkZWNpc2lvbnMgPSBbXQogICAgdHJ5OgogICAgICAgIGltcG9ydCBqc29uIGFzIF9qc29uCiAgICAgICAgd2l0aCBvcGVuKGZpbGVwYXRoKSBhcyBmOgogICAgICAgICAgICBmb3IgaSwgbGluZSBpbiBlbnVtZXJhdGUoZik6CiAgICAgICAgICAgICAgICBpZiBpIDwgb2Zmc2V0OgogICAgICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgICAgICBpZiBsZW4oZGVjaXNpb25zKSA+PSBsaW1pdDoKICAgICAgICAgICAgICAgICAgICBicmVhawogICAgICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgICAgIGRlY2lzaW9ucy5hcHBlbmQoX2pzb24ubG9hZHMobGluZS5zdHJpcCgpKSkKICAgICAgICAgICAgICAgIGV4Y2VwdCBfanNvbi5KU09ORGVjb2RlRXJyb3I6CiAgICAgICAgICAgICAgICAgICAgY29udGludWUKICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMCwgZiJGYWlsZWQgdG8gcmVhZCBkZWNpc2lvbnM6IHtlfSIpCgogICAgcmV0dXJuIHsKICAgICAgICAiZmlsZSI6IGZpbGVuYW1lLAogICAgICAgICJvZmZzZXQiOiBvZmZzZXQsCiAgICAgICAgImxpbWl0IjogbGltaXQsCiAgICAgICAgImNvdW50IjogbGVuKGRlY2lzaW9ucyksCiAgICAgICAgImRlY2lzaW9ucyI6IGRlY2lzaW9ucywKICAgIH0KCgojIOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAojIE1MIFBvbGljeSBJbmZlcmVuY2UgRW5kcG9pbnRzCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCgojIExhenktbG9hZGVkIHBvbGljeSBpbmZlcmVuY2Ugc2VydmljZSAob25seSBsb2FkcyB3aGVuIGZpcnN0IGNhbGxlZCkKX3BvbGljeV9zZXJ2aWNlID0gTm9uZQpfcG9saWN5X3NlcnZpY2VfaW5pdF9hdHRlbXB0ZWQgPSBGYWxzZQoKCmRlZiBfZ2V0X3BvbGljeV9zZXJ2aWNlKCk6CiAgICAiIiJHZXQgb3IgaW5pdGlhbGl6ZSB0aGUgcG9saWN5IGluZmVyZW5jZSBzZXJ2aWNlIChsYXp5IGxvYWRpbmcpLiIiIgogICAgZ2xvYmFsIF9wb2xpY3lfc2VydmljZSwgX3BvbGljeV9zZXJ2aWNlX2luaXRfYXR0ZW1wdGVkCiAgICBpZiBfcG9saWN5X3NlcnZpY2UgaXMgTm9uZSBhbmQgbm90IF9wb2xpY3lfc2VydmljZV9pbml0X2F0dGVtcHRlZDoKICAgICAgICBfcG9saWN5X3NlcnZpY2VfaW5pdF9hdHRlbXB0ZWQgPSBUcnVlCiAgICAgICAgdHJ5OgogICAgICAgICAgICBmcm9tIG1sLnNlcnZpbmcucG9saWN5X3NlcnZlciBpbXBvcnQgUG9saWN5SW5mZXJlbmNlU2VydmljZQogICAgICAgICAgICBfcG9saWN5X3NlcnZpY2UgPSBQb2xpY3lJbmZlcmVuY2VTZXJ2aWNlKCkKICAgICAgICAgICAgaWYgX3BvbGljeV9zZXJ2aWNlLmxvYWQoKToKICAgICAgICAgICAgICAgIHByaW50KGYiW01MXSBQb2xpY3kgbW9kZWwgbG9hZGVkIG9uIHtfcG9saWN5X3NlcnZpY2UuZGV2aWNlfSIpCiAgICAgICAgICAgIGVsc2U6CiAgICAgICAgICAgICAgICBwcmludChmIltNTF0gUG9saWN5IG1vZGVsIG5vdCBhdmFpbGFibGU6IHtfcG9saWN5X3NlcnZpY2UuX2xvYWRfZXJyb3J9IikKICAgICAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgICAgIHByaW50KGYiW01MXSBQb2xpY3kgc2VydmljZSBpbml0IGZhaWxlZDoge2V9IikKICAgIHJldHVybiBfcG9saWN5X3NlcnZpY2UKCgpAYXBwLnBvc3QoIi9hcGkvbWwvcHJlZGljdCIpCmFzeW5jIGRlZiBtbF9wcmVkaWN0KHJlcXVl' +
'c3Q6IEZhc3RBUElSZXF1ZXN0KToKICAgICIiIlByZWRpY3QgYSBtYWNyby1hY3Rpb24gZnJvbSBhIGdhbWUgc3RhdGUgc25hcHNob3QuCgogICAgQWNjZXB0cyBEZWNpc2lvblNuYXBzaG90LXNoYXBlZCBKU09OIGZyb20gdGhlIEphdmEgYmF0Y2ggcnVubmVyLgogICAgUmV0dXJucyB0aGUgbGVhcm5lZCBwb2xpY3kncyByZWNvbW1lbmRlZCBhY3Rpb24uCgogICAgUmVxdWVzdCBib2R5OgogICAgICAgIHsKICAgICAgICAgICAgInR1cm4iOiA1LAogICAgICAgICAgICAicGhhc2UiOiAibWFpbl8xIiwKICAgICAgICAgICAgImFjdGl2ZV9zZWF0IjogMCwKICAgICAgICAgICAgInBsYXllcnMiOiBbLi4uXSwKICAgICAgICAgICAgImFyY2hldHlwZSI6ICJhZ2dybyIsCiAgICAgICAgICAgICJ0ZW1wZXJhdHVyZSI6IDEuMCwKICAgICAgICAgICAgImdyZWVkeSI6IGZhbHNlCiAgICAgICAgfQoKICAgIFJlc3BvbnNlOgogICAgICAgIHsKICAgICAgICAgICAgImFjdGlvbiI6ICJjYXN0X2NyZWF0dXJlIiwKICAgICAgICAgICAgImFjdGlvbl9pbmRleCI6IDAsCiAgICAgICAgICAgICJjb25maWRlbmNlIjogMC43MywKICAgICAgICAgICAgInByb2JhYmlsaXRpZXMiOiB7Li4ufSwKICAgICAgICAgICAgImluZmVyZW5jZV9tcyI6IDIuMwogICAgICAgIH0KICAgICIiIgogICAgc3ZjID0gX2dldF9wb2xpY3lfc2VydmljZSgpCiAgICBpZiBzdmMgaXMgTm9uZSBvciBub3Qgc3ZjLl9sb2FkZWQ6CiAgICAgICAgZGV0YWlsID0gIlBvbGljeSBtb2RlbCBub3QgbG9hZGVkLiAiCiAgICAgICAgaWYgc3ZjOgogICAgICAgICAgICBkZXRhaWwgKz0gc3ZjLl9sb2FkX2Vycm9yIG9yICJUcmFpbiBhIG1vZGVsIGZpcnN0LiIKICAgICAgICBlbHNlOgogICAgICAgICAgICBkZXRhaWwgKz0gIlB5VG9yY2ggbWF5IG5vdCBiZSBpbnN0YWxsZWQuIgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oc3RhdHVzX2NvZGU9NTAzLCBkZXRhaWw9ZGV0YWlsKQoKICAgIGJvZHkgPSBhd2FpdCByZXF1ZXN0Lmpzb24oKQogICAgcGxheXN0eWxlID0gYm9keS5wb3AoImFyY2hldHlwZSIsICJtaWRyYW5nZSIpCiAgICB0ZW1wZXJhdHVyZSA9IGJvZHkucG9wKCJ0ZW1wZXJhdHVyZSIsIDEuMCkKICAgIGdyZWVkeSA9IGJvZHkucG9wKCJncmVlZHkiLCBGYWxzZSkKCiAgICByZXN1bHQgPSBzdmMucHJlZGljdCgKICAgICAgICBkZWNpc2lvbl9zbmFwc2hvdD1ib2R5LAogICAgICAgIHBsYXlzdHlsZT1wbGF5c3R5bGUsCiAgICAgICAgdGVtcGVyYXR1cmU9dGVtcGVyYXR1cmUsCiAgICAgICAgZ3JlZWR5PWdyZWVkeSwKICAgICkKCiAgICBpZiAiZXJyb3IiIGluIHJlc3VsdDoKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKHN0YXR1c19jb2RlPTUwMCwgZGV0YWlsPXJlc3VsdFsiZXJyb3IiXSkKICAgIHJldHVybiByZXN1bHQKCgpAYXBwLnBvc3QoIi9hcGkvbWwvcHJlZGljdC9iYXRjaCIpCmFzeW5jIGRlZiBtbF9wcmVkaWN0X2JhdGNoKHJlcXVlc3Q6IEZhc3RBUElSZXF1ZXN0KToKICAgICIiIlByZWRpY3QgYWN0aW9ucyBmb3IgbXVsdGlwbGUgc25hcHNob3RzIGF0IG9uY2UuCgogICAgUmVxdWVzdCBib2R5OiB7InNuYXBzaG90cyI6IFsuLi5dLCAiZ3JlZWR5IjogdHJ1ZX0KICAgICIiIgogICAgc3ZjID0gX2dldF9wb2xpY3lfc2VydmljZSgpCiAgICBpZiBzdmMgaXMgTm9uZSBvciBub3Qgc3ZjLl9sb2FkZWQ6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbihzdGF0dXNfY29kZT01MDMsIGRldGFpbD0iUG9saWN5IG1vZGVsIG5vdCBsb2FkZWQiKQoKICAgIGJvZHkgPSBhd2FpdCByZXF1ZXN0Lmpzb24oKQogICAgc25hcHNob3RzID0gYm9keS5nZXQoInNuYXBzaG90cyIsIFtdKQogICAgZ3JlZWR5ID0gYm9keS5nZXQoImdyZWVkeSIsIFRydWUpCgogICAgaWYgbm90IHNuYXBzaG90czoKICAgICAgICByZXR1cm4geyJyZXN1bHRzIjogW119CgogICAgcmVzdWx0cyA9IHN2Yy5wcmVkaWN0X2JhdGNoKHNuYXBzaG90cywgZ3JlZWR5PWdyZWVkeSkKICAgIHJldHVybiB7InJlc3VsdHMiOiByZXN1bHRzLCAiY291bnQiOiBsZW4ocmVzdWx0cyl9CgoKQGFwcC5nZXQoIi9hcGkvbWwvbW9kZWwiKQphc3luYyBkZWYgbWxfbW9kZWxfaW5mbygpOgogICAgIiIiR2V0IGluZm9ybWF0aW9uIGFib3V0IHRoZSBsb2FkZWQgcG9saWN5IG1vZGVsLiIiIgogICAgc3ZjID0gX2dldF9wb2xpY3lfc2VydmljZSgpCiAgICBpZiBzdmMgaXMgTm9uZToKICAgICAgICByZXR1cm4gewogICAgICAgICAg' +
'ICAibG9hZGVkIjogRmFsc2UsCiAgICAgICAgICAgICJlcnJvciI6ICJQb2xpY3kgc2VydmljZSBub3QgaW5pdGlhbGl6ZWQiLAogICAgICAgICAgICAidG9yY2hfYXZhaWxhYmxlIjogRmFsc2UsCiAgICAgICAgfQogICAgcmV0dXJuIHN2Yy5nZXRfc3RhdHVzKCkKCgpAYXBwLnBvc3QoIi9hcGkvbWwvcmVsb2FkIikKYXN5bmMgZGVmIG1sX3JlbG9hZF9tb2RlbChjaGVja3BvaW50OiBzdHIgPSBOb25lKToKICAgICIiIkhvdC1yZWxvYWQgYSBwb2xpY3kgbW9kZWwgY2hlY2twb2ludC4iIiIKICAgIHN2YyA9IF9nZXRfcG9saWN5X3NlcnZpY2UoKQogICAgaWYgc3ZjIGlzIE5vbmU6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbihzdGF0dXNfY29kZT01MDMsIGRldGFpbD0iUG9saWN5IHNlcnZpY2Ugbm90IGF2YWlsYWJsZSIpCgogICAgIyBJZiBzZXJ2aWNlIGhhc24ndCBsb2FkZWQgeWV0LCB0cnkgZnVsbCBsb2FkCiAgICBpZiBub3Qgc3ZjLl9sb2FkZWQ6CiAgICAgICAgb2sgPSBzdmMubG9hZCgpCiAgICBlbHNlOgogICAgICAgIG9rID0gc3ZjLnJlbG9hZChjaGVja3BvaW50KQoKICAgIHJldHVybiB7CiAgICAgICAgInN1Y2Nlc3MiOiBvaywKICAgICAgICAic3RhdHVzIjogc3ZjLmdldF9zdGF0dXMoKSwKICAgIH0KCgojIOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAojIE1MIFRyYWluaW5nIE1hbmFnZW1lbnQgRW5kcG9pbnRzCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCgojIFRyYWluaW5nIHN0YXRlIHRyYWNraW5nCl90cmFpbmluZ19zdGF0ZSA9IHsKICAgICJydW5uaW5nIjogRmFsc2UsCiAgICAicHJvZ3Jlc3MiOiAwLAogICAgInRvdGFsX2Vwb2NocyI6IDAsCiAgICAiY3VycmVudF9lcG9jaCI6IDAsCiAgICAicGhhc2UiOiAiaWRsZSIsICAjIGlkbGUgfCBidWlsZGluZyB8IHRyYWluaW5nIHwgZXZhbHVhdGluZyB8IGRvbmUgfCBlcnJvcgogICAgIm1lc3NhZ2UiOiAiIiwKICAgICJtZXRyaWNzIjogTm9uZSwgICMgbGF0ZXN0IGVwb2NoIG1ldHJpY3MKICAgICJyZXN1bHQiOiBOb25lLCAgICMgZmluYWwgdHJhaW5pbmcgcmVzdWx0CiAgICAiZXJyb3IiOiBOb25lLAogICAgInN0YXJ0ZWRfYXQiOiBOb25lLAp9CgoKZGVmIF9ydW5fdHJhaW5pbmdfcGlwZWxpbmUoCiAgICByZXN1bHRzX2Rpcjogc3RyLAogICAgZXBvY2hzOiBpbnQsCiAgICBscjogZmxvYXQsCiAgICBiYXRjaF9zaXplOiBpbnQsCiAgICBwYXRpZW5jZTogaW50LAogICAgcmVidWlsZF9kYXRhc2V0OiBib29sLAopOgogICAgIiIiUnVuIHRoZSBmdWxsIE1MIHRyYWluaW5nIHBpcGVsaW5lIGluIGEgYmFja2dyb3VuZCB0aHJlYWQuIiIiCiAgICBnbG9iYWwgX3RyYWluaW5nX3N0YXRlCiAgICB0cnk6CiAgICAgICAgaW1wb3J0IHN5cwogICAgICAgIHByb2plY3Rfcm9vdCA9IHN0cihQYXRoKF9fZmlsZV9fKS5wYXJlbnQpCiAgICAgICAgaWYgcHJvamVjdF9yb290IG5vdCBpbiBzeXMucGF0aDoKICAgICAgICAgICAgc3lzLnBhdGguaW5zZXJ0KDAsIHByb2plY3Rfcm9vdCkKCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJydW5uaW5nIl0gPSBUcnVlCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJzdGFydGVkX2F0Il0gPSBkYXRldGltZS5ub3coKS5pc29mb3JtYXQoKQogICAgICAgIF90cmFpbmluZ19zdGF0ZVsiZXJyb3IiXSA9IE5vbmUKICAgICAgICBfdHJhaW5pbmdfc3RhdGVbInJlc3VsdCJdID0gTm9uZQoKICAgICAgICBkYXRhX2RpciA9IG9zLnBhdGguam9pbihwcm9qZWN0X3Jvb3QsICJtbCIsICJtb2RlbHMiKQogICAgICAgIGNrcHRfZGlyID0gb3MucGF0aC5qb2luKHByb2plY3Rfcm9vdCwgIm1sIiwgIm1vZGVscyIsICJjaGVja3BvaW50cyIpCiAgICAgICAgb3MubWFrZWRpcnMoZGF0YV9kaXIsIGV4aXN0X29rPVRydWUpCiAgICAgICAgb3MubWFrZWRpcnMoY2twdF9kaXIsIGV4aXN0X29r' +
'PVRydWUpCgogICAgICAgICMgLS0tIFBoYXNlIDE6IEJ1aWxkIERhdGFzZXQgLS0tCiAgICAgICAgdHJhaW5fcGF0aCA9IG9zLnBhdGguam9pbihkYXRhX2RpciwgInRyYWluLm5weiIpCiAgICAgICAgaWYgcmVidWlsZF9kYXRhc2V0IG9yIG5vdCBvcy5wYXRoLmV4aXN0cyh0cmFpbl9wYXRoKToKICAgICAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJwaGFzZSJdID0gImJ1aWxkaW5nIgogICAgICAgICAgICBfdHJhaW5pbmdfc3RhdGVbIm1lc3NhZ2UiXSA9ICJMb2FkaW5nIGNhcmQgZW1iZWRkaW5ncyAmIGJ1aWxkaW5nIGRhdGFzZXQuLi4iCiAgICAgICAgICAgIHByaW50KCJbTUwgVHJhaW5dIEJ1aWxkaW5nIGRhdGFzZXQgKGxvYWRpbmcgZW1iZWRkaW5ncywgbWF5IGF1dG8tZG93bmxvYWQpLi4uIikKCiAgICAgICAgICAgIGZyb20gbWwuZGF0YS5kYXRhc2V0X2J1aWxkZXIgaW1wb3J0IGJ1aWxkX2RhdGFzZXQsIHNwbGl0X2RhdGFzZXQsIHNhdmVfZGF0YXNldAogICAgICAgICAgICBkYXRhc2V0ID0gYnVpbGRfZGF0YXNldChyZXN1bHRzX2Rpcj1yZXN1bHRzX2RpcikKICAgICAgICAgICAgaWYgbm90IGRhdGFzZXQ6CiAgICAgICAgICAgICAgICByYWlzZSBSdW50aW1lRXJyb3IoIk5vIHRyYWluaW5nIGRhdGEgcHJvZHVjZWQuIENoZWNrIHNlcnZlciBsb2cgZm9yIGRldGFpbHMuIikKICAgICAgICAgICAgdHJhaW5fZHMsIHZhbF9kcywgdGVzdF9kcyA9IHNwbGl0X2RhdGFzZXQoZGF0YXNldCkKICAgICAgICAgICAgc2F2ZV9kYXRhc2V0KHRyYWluX2RzLCBvcy5wYXRoLmpvaW4oZGF0YV9kaXIsICJ0cmFpbi5ucHoiKSkKICAgICAgICAgICAgc2F2ZV9kYXRhc2V0KHZhbF9kcywgb3MucGF0aC5qb2luKGRhdGFfZGlyLCAidmFsLm5weiIpKQogICAgICAgICAgICBzYXZlX2RhdGFzZXQodGVzdF9kcywgb3MucGF0aC5qb2luKGRhdGFfZGlyLCAidGVzdC5ucHoiKSkKICAgICAgICAgICAgdG90YWxfc2FtcGxlcyA9IGxlbihkYXRhc2V0WyJzdGF0ZXMiXSkKICAgICAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJtZXNzYWdlIl0gPSBmIkRhdGFzZXQgYnVpbHQ6IHt0b3RhbF9zYW1wbGVzfSBzYW1wbGVzIgogICAgICAgICAgICBwcmludChmIltNTCBUcmFpbl0gRGF0YXNldCBidWlsdDoge3RvdGFsX3NhbXBsZXN9IHNhbXBsZXMiKQoKICAgICAgICAjIC0tLSBQaGFzZSAyOiBUcmFpbiAtLS0KICAgICAgICBfdHJhaW5pbmdfc3RhdGVbInBoYXNlIl0gPSAidHJhaW5pbmciCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJ0b3RhbF9lcG9jaHMiXSA9IGVwb2NocwogICAgICAgIF90cmFpbmluZ19zdGF0ZVsiY3VycmVudF9lcG9jaCJdID0gMAogICAgICAgIF90cmFpbmluZ19zdGF0ZVsibWVzc2FnZSJdID0gZiJUcmFpbmluZyBwb2xpY3kgbmV0d29yayAoe2Vwb2Noc30gZXBvY2hzKS4uLiIKICAgICAgICBwcmludChmIltNTCBUcmFpbl0gU3RhcnRpbmcgdHJhaW5pbmc6IHtlcG9jaHN9IGVwb2NocywgbHI9e2xyfSwgYnM9e2JhdGNoX3NpemV9IikKCiAgICAgICAgaW1wb3J0IG51bXB5IGFzIG5wCiAgICAgICAgaW1wb3J0IHRvcmNoCiAgICAgICAgZnJvbSBtbC50cmFpbmluZy5wb2xpY3lfbmV0d29yayBpbXBvcnQgUG9saWN5TmV0d29yawogICAgICAgIGZyb20gbWwudHJhaW5pbmcudHJhaW5lciBpbXBvcnQgU3VwZXJ2aXNlZFRyYWluZXIKCiAgICAgICAgZGVmIGxvYWRfbnB6X3NwbGl0KHBhdGgpOgogICAgICAgICAgICBkYXRhID0gbnAubG9hZChwYXRoKQogICAgICAgICAgICByZXR1cm4gZGF0YVsic3RhdGVzIl0uYXN0eXBlKG5wLmZsb2F0MzIpLCBkYXRhWyJsYWJlbHMiXS5hc3R5cGUobnAuaW50NjQpCgogICAgICAgIHRyYWluX3N0YXRlcywgdHJhaW5fbGFiZWxzID0gbG9hZF9ucHpfc3BsaXQodHJhaW5fcGF0aCkKICAgICAgICB2YWxfcGF0aCA9IG9zLnBhdGguam9pbihkYXRhX2RpciwgInZhbC5ucHoiKQogICAgICAgIHZhbF9zdGF0ZXMsIHZhbF9sYWJlbHMgPSBsb2FkX25wel9zcGxpdCh2YWxfcGF0aCkKCiAgICAgICAgYWN0dWFsX2RpbSA9IHRyYWluX3N0YXRlcy5zaGFwZVsxXQogICAgICAgIG1vZGVsID0gUG9saWN5TmV0d29yayhpbnB1dF9kaW09YWN0dWFsX2RpbSkKCiAgICAgICAgZGV2aWNlID0gImNwdSIKICAgICAgICBpZiB0b3JjaC5jdWRhLmlzX2F2YWlsYWJsZSgpOgogICAgICAgICAgICBkZXZpY2UgPSAiY3VkYSIKICAgICAgICBlbGlmIGhhc2F0dHIodG9yY2guYmFja2VuZHMsICJtcHMiKSBhbmQgdG9yY2guYmFja2VuZHMu' +
'bXBzLmlzX2F2YWlsYWJsZSgpOgogICAgICAgICAgICBkZXZpY2UgPSAibXBzIgoKICAgICAgICB0cmFpbmVyID0gU3VwZXJ2aXNlZFRyYWluZXIoCiAgICAgICAgICAgIG1vZGVsPW1vZGVsLAogICAgICAgICAgICBkZXZpY2U9ZGV2aWNlLAogICAgICAgICAgICBsZWFybmluZ19yYXRlPWxyLAogICAgICAgICAgICBiYXRjaF9zaXplPWJhdGNoX3NpemUsCiAgICAgICAgICAgIGVwb2Nocz1lcG9jaHMsCiAgICAgICAgICAgIHBhdGllbmNlPXBhdGllbmNlLAogICAgICAgICAgICBjaGVja3BvaW50X2Rpcj1ja3B0X2RpciwKICAgICAgICApCgogICAgICAgICMgSG9vayBpbnRvIHRyYWluZXIgdG8gcmVwb3J0IHByb2dyZXNzCiAgICAgICAgb3JpZ2luYWxfdHJhaW4gPSB0cmFpbmVyLnRyYWluCgogICAgICAgIGRlZiBwYXRjaGVkX3RyYWluKHRyYWluX3MsIHRyYWluX2wsIHZhbF9zLCB2YWxfbCk6CiAgICAgICAgICAgICMgV2UnbGwgbW9uaXRvciB0aGUgY2hlY2twb2ludCBkaXIgZm9yIHByb2dyZXNzCiAgICAgICAgICAgIHJldHVybiBvcmlnaW5hbF90cmFpbih0cmFpbl9zLCB0cmFpbl9sLCB2YWxfcywgdmFsX2wpCgogICAgICAgIHN1bW1hcnkgPSB0cmFpbmVyLnRyYWluKHRyYWluX3N0YXRlcywgdHJhaW5fbGFiZWxzLCB2YWxfc3RhdGVzLCB2YWxfbGFiZWxzKQoKICAgICAgICBfdHJhaW5pbmdfc3RhdGVbImN1cnJlbnRfZXBvY2giXSA9IHN1bW1hcnkuZ2V0KCJlcG9jaHNfdHJhaW5lZCIsIGVwb2NocykKICAgICAgICBfdHJhaW5pbmdfc3RhdGVbIm1ldHJpY3MiXSA9IHN1bW1hcnkKCiAgICAgICAgIyAtLS0gUGhhc2UgMzogRXZhbHVhdGUgLS0tCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJwaGFzZSJdID0gImV2YWx1YXRpbmciCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJtZXNzYWdlIl0gPSAiRXZhbHVhdGluZyBvbiB0ZXN0IHNldC4uLiIKCiAgICAgICAgdGVzdF9wYXRoID0gb3MucGF0aC5qb2luKGRhdGFfZGlyLCAidGVzdC5ucHoiKQogICAgICAgIGV2YWxfcmVzdWx0cyA9IE5vbmUKICAgICAgICBpZiBvcy5wYXRoLmV4aXN0cyh0ZXN0X3BhdGgpOgogICAgICAgICAgICBmcm9tIG1sLnRyYWluaW5nLnRyYWluZXIgaW1wb3J0IGxvYWRfY2hlY2twb2ludCwgZXZhbHVhdGVfbW9kZWwKICAgICAgICAgICAgdGVzdF9zdGF0ZXMsIHRlc3RfbGFiZWxzID0gbG9hZF9ucHpfc3BsaXQodGVzdF9wYXRoKQogICAgICAgICAgICBiZXN0X21vZGVsLCBfID0gbG9hZF9jaGVja3BvaW50KHN1bW1hcnlbImNoZWNrcG9pbnRfcGF0aCJdLCBkZXZpY2UpCiAgICAgICAgICAgIGV2YWxfcmVzdWx0cyA9IGV2YWx1YXRlX21vZGVsKGJlc3RfbW9kZWwsIHRlc3Rfc3RhdGVzLCB0ZXN0X2xhYmVscywgZGV2aWNlKQoKICAgICAgICAgICAgZXZhbF9wYXRoID0gb3MucGF0aC5qb2luKGNrcHRfZGlyLCAiZXZhbF9yZXN1bHRzLmpzb24iKQogICAgICAgICAgICB3aXRoIG9wZW4oZXZhbF9wYXRoLCAidyIpIGFzIGY6CiAgICAgICAgICAgICAgICBqc29uLmR1bXAoZXZhbF9yZXN1bHRzLCBmLCBpbmRlbnQ9MikKCiAgICAgICAgIyAtLS0gRG9uZSAtLS0KICAgICAgICBfdHJhaW5pbmdfc3RhdGVbInBoYXNlIl0gPSAiZG9uZSIKICAgICAgICBfdHJhaW5pbmdfc3RhdGVbInJ1bm5pbmciXSA9IEZhbHNlCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJyZXN1bHQiXSA9IHsKICAgICAgICAgICAgInRyYWluaW5nIjogc3VtbWFyeSwKICAgICAgICAgICAgImV2YWx1YXRpb24iOiBldmFsX3Jlc3VsdHMsCiAgICAgICAgICAgICJjaGVja3BvaW50Ijogc3VtbWFyeS5nZXQoImNoZWNrcG9pbnRfcGF0aCIsICIiKSwKICAgICAgICAgICAgImRldmljZSI6IGRldmljZSwKICAgICAgICB9CiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJtZXNzYWdlIl0gPSBmIlRyYWluaW5nIGNvbXBsZXRlISBCZXN0IHZhbCBhY2M6IHtzdW1tYXJ5LmdldCgnYmVzdF92YWxfYWNjdXJhY3knLCAwKTouMSV9IgogICAgICAgIHByaW50KGYiW01MIFRyYWluXSBDb21wbGV0ZToge3N1bW1hcnkuZ2V0KCdiZXN0X3ZhbF9hY2N1cmFjeScsIDApOi4xJX0gdmFsIGFjY3VyYWN5IikKCiAgICAgICAgIyBBdXRvLXJlbG9hZCBwb2xpY3kgc2VydmVyIHdpdGggbmV3IGNoZWNrcG9pbnQKICAgICAgICBnbG9iYWwgX3BvbGljeV9zZXJ2aWNlLCBfcG9saWN5X3NlcnZpY2VfaW5pdF9hdHRlbXB0ZWQKICAgICAgICBpZiBfcG9saWN5X3NlcnZpY2UgYW5kIF9wb2xpY3lfc2VydmljZS5fbG9hZGVkOgogICAg' +
'ICAgICAgICBfcG9saWN5X3NlcnZpY2UucmVsb2FkKHN1bW1hcnkuZ2V0KCJjaGVja3BvaW50X3BhdGgiKSkKICAgICAgICAgICAgcHJpbnQoIltNTCBUcmFpbl0gUG9saWN5IHNlcnZlciByZWxvYWRlZCB3aXRoIG5ldyBjaGVja3BvaW50IikKICAgICAgICBlbGlmIG5vdCBfcG9saWN5X3NlcnZpY2VfaW5pdF9hdHRlbXB0ZWQ6CiAgICAgICAgICAgIF9wb2xpY3lfc2VydmljZV9pbml0X2F0dGVtcHRlZCA9IEZhbHNlICAjIEFsbG93IHJlLWluaXQgd2l0aCBuZXcgbW9kZWwKCiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgaW1wb3J0IHRyYWNlYmFjawogICAgICAgIF90cmFpbmluZ19zdGF0ZVsicGhhc2UiXSA9ICJlcnJvciIKICAgICAgICBfdHJhaW5pbmdfc3RhdGVbInJ1bm5pbmciXSA9IEZhbHNlCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJlcnJvciJdID0gc3RyKGUpCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJtZXNzYWdlIl0gPSBmIlRyYWluaW5nIGZhaWxlZDoge2V9IgogICAgICAgIHByaW50KGYiW01MIFRyYWluXSBFUlJPUjoge2V9IikKICAgICAgICB0cmFjZWJhY2sucHJpbnRfZXhjKCkKCgpAYXBwLnBvc3QoIi9hcGkvbWwvdHJhaW4iKQphc3luYyBkZWYgbWxfc3RhcnRfdHJhaW5pbmcocmVxdWVzdDogRmFzdEFQSVJlcXVlc3QpOgogICAgIiIiVHJpZ2dlciBNTCB0cmFpbmluZyBwaXBlbGluZSBmcm9tIHRoZSB3ZWIgVUkuCgogICAgUmVxdWVzdCBib2R5IChhbGwgb3B0aW9uYWwpOgogICAgICAgIHsKICAgICAgICAgICAgImVwb2NocyI6IDUwLAogICAgICAgICAgICAibHIiOiAwLjAwMSwKICAgICAgICAgICAgImJhdGNoU2l6ZSI6IDI1NiwKICAgICAgICAgICAgInBhdGllbmNlIjogMTAsCiAgICAgICAgICAgICJyZWJ1aWxkRGF0YXNldCI6IHRydWUKICAgICAgICB9CiAgICAiIiIKICAgIGlmIF90cmFpbmluZ19zdGF0ZVsicnVubmluZyJdOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNDA5LCAiVHJhaW5pbmcgYWxyZWFkeSBpbiBwcm9ncmVzcyIpCgogICAgYm9keSA9IGF3YWl0IHJlcXVlc3QuanNvbigpIGlmIGF3YWl0IHJlcXVlc3QuYm9keSgpIGVsc2Uge30KICAgIGVwb2NocyA9IGJvZHkuZ2V0KCJlcG9jaHMiLCA1MCkKICAgIGxyID0gYm9keS5nZXQoImxyIiwgMC4wMDEpCiAgICBiYXRjaF9zaXplID0gYm9keS5nZXQoImJhdGNoU2l6ZSIsIDI1NikKICAgIHBhdGllbmNlID0gYm9keS5nZXQoInBhdGllbmNlIiwgMTApCiAgICByZWJ1aWxkID0gYm9keS5nZXQoInJlYnVpbGREYXRhc2V0IiwgVHJ1ZSkKCiAgICByZXN1bHRzX2RpciA9IG9zLnBhdGguam9pbihzdHIoUGF0aChfX2ZpbGVfXykucGFyZW50KSwgInJlc3VsdHMiKQoKICAgICMgUmVzZXQgc3RhdGUKICAgIF90cmFpbmluZ19zdGF0ZS51cGRhdGUoewogICAgICAgICJydW5uaW5nIjogVHJ1ZSwKICAgICAgICAicHJvZ3Jlc3MiOiAwLAogICAgICAgICJ0b3RhbF9lcG9jaHMiOiBlcG9jaHMsCiAgICAgICAgImN1cnJlbnRfZXBvY2giOiAwLAogICAgICAgICJwaGFzZSI6ICJzdGFydGluZyIsCiAgICAgICAgIm1lc3NhZ2UiOiAiSW5pdGlhbGl6aW5nIHRyYWluaW5nIHBpcGVsaW5lLi4uIiwKICAgICAgICAibWV0cmljcyI6IE5vbmUsCiAgICAgICAgInJlc3VsdCI6IE5vbmUsCiAgICAgICAgImVycm9yIjogTm9uZSwKICAgIH0pCgogICAgIyBSdW4gaW4gYmFja2dyb3VuZCB0aHJlYWQKICAgIHRocmVhZCA9IHRocmVhZGluZy5UaHJlYWQoCiAgICAgICAgdGFyZ2V0PV9ydW5fdHJhaW5pbmdfcGlwZWxpbmUsCiAgICAgICAgYXJncz0ocmVzdWx0c19kaXIsIGVwb2NocywgbHIsIGJhdGNoX3NpemUsIHBhdGllbmNlLCByZWJ1aWxkKSwKICAgICAgICBkYWVtb249VHJ1ZSwKICAgICkKICAgIHRocmVhZC5zdGFydCgpCgogICAgcmV0dXJuIHsKICAgICAgICAic3RhdHVzIjogInN0YXJ0ZWQiLAogICAgICAgICJjb25maWciOiB7CiAgICAgICAgICAgICJlcG9jaHMiOiBlcG9jaHMsCiAgICAgICAgICAgICJsciI6IGxyLAogICAgICAgICAgICAiYmF0Y2hTaXplIjogYmF0Y2hfc2l6ZSwKICAgICAgICAgICAgInBhdGllbmNlIjogcGF0aWVuY2UsCiAgICAgICAgICAgICJyZWJ1aWxkRGF0YXNldCI6IHJlYnVpbGQsCiAgICAgICAgfSwKICAgIH0KCgpAYXBwLmdldCgiL2FwaS9tbC90cmFpbi9zdGF0dXMiKQphc3luYyBkZWYgbWxfdHJhaW5pbmdfc3RhdHVzKCk6CiAgICAiIiJHZXQgY3VycmVudCB0cmFpbmluZyBwaXBlbGluZSBzdGF0dXMu' +
'IiIiCiAgICByZXR1cm4gZGljdChfdHJhaW5pbmdfc3RhdGUpCgoKQGFwcC5nZXQoIi9hcGkvbWwvZGF0YS9zdGF0dXMiKQphc3luYyBkZWYgbWxfZGF0YV9zdGF0dXMoKToKICAgICIiIkdldCBzdGF0dXMgb2YgYXZhaWxhYmxlIHRyYWluaW5nIGRhdGEgYW5kIG1vZGVsIGNoZWNrcG9pbnRzLiIiIgogICAgcHJvamVjdF9yb290ID0gUGF0aChfX2ZpbGVfXykucGFyZW50CiAgICByZXN1bHRzX2RpciA9IHByb2plY3Rfcm9vdCAvICJyZXN1bHRzIgogICAgZGF0YV9kaXIgPSBwcm9qZWN0X3Jvb3QgLyAibWwiIC8gIm1vZGVscyIKICAgIGNrcHRfZGlyID0gZGF0YV9kaXIgLyAiY2hlY2twb2ludHMiCgogICAgIyBEZWNpc2lvbiBsb2cgZmlsZXMKICAgIGRlY2lzaW9uX2ZpbGVzID0gW10KICAgIGlmIHJlc3VsdHNfZGlyLmV4aXN0cygpOgogICAgICAgIGZvciBmIGluIHNvcnRlZChyZXN1bHRzX2Rpci5nbG9iKCJtbC1kZWNpc2lvbnMtKi5qc29ubCIpKToKICAgICAgICAgICAgbGluZV9jb3VudCA9IDAKICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgd2l0aCBvcGVuKGYsICJyIikgYXMgZmg6CiAgICAgICAgICAgICAgICAgICAgbGluZV9jb3VudCA9IHN1bSgxIGZvciBfIGluIGZoKQogICAgICAgICAgICBleGNlcHQgRXhjZXB0aW9uOgogICAgICAgICAgICAgICAgcGFzcwogICAgICAgICAgICBkZWNpc2lvbl9maWxlcy5hcHBlbmQoewogICAgICAgICAgICAgICAgIm5hbWUiOiBmLm5hbWUsCiAgICAgICAgICAgICAgICAic2l6ZSI6IGYuc3RhdCgpLnN0X3NpemUsCiAgICAgICAgICAgICAgICAiZGVjaXNpb25zIjogbGluZV9jb3VudCwKICAgICAgICAgICAgfSkKCiAgICAjIERhdGFzZXQgZmlsZXMKICAgIGRhdGFzZXRzID0ge30KICAgIGZvciBzcGxpdCBpbiBbInRyYWluIiwgInZhbCIsICJ0ZXN0Il06CiAgICAgICAgcGF0aCA9IGRhdGFfZGlyIC8gZiJ7c3BsaXR9Lm5weiIKICAgICAgICBpZiBwYXRoLmV4aXN0cygpOgogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICBpbXBvcnQgbnVtcHkgYXMgbnAKICAgICAgICAgICAgICAgIGRhdGEgPSBucC5sb2FkKHN0cihwYXRoKSkKICAgICAgICAgICAgICAgIGRhdGFzZXRzW3NwbGl0XSA9IHsKICAgICAgICAgICAgICAgICAgICAic2FtcGxlcyI6IGludChkYXRhWyJzdGF0ZXMiXS5zaGFwZVswXSksCiAgICAgICAgICAgICAgICAgICAgImZlYXR1cmVzIjogaW50KGRhdGFbInN0YXRlcyJdLnNoYXBlWzFdKSwKICAgICAgICAgICAgICAgICAgICAic2l6ZSI6IHBhdGguc3RhdCgpLnN0X3NpemUsCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIGV4Y2VwdCBFeGNlcHRpb246CiAgICAgICAgICAgICAgICBkYXRhc2V0c1tzcGxpdF0gPSB7InNpemUiOiBwYXRoLnN0YXQoKS5zdF9zaXplfQoKICAgICMgQ2hlY2twb2ludHMKICAgIGNoZWNrcG9pbnRzID0gW10KICAgIGlmIGNrcHRfZGlyLmV4aXN0cygpOgogICAgICAgIGZvciBmIGluIHNvcnRlZChja3B0X2Rpci5nbG9iKCIqLnB0IikpOgogICAgICAgICAgICBjaGVja3BvaW50cy5hcHBlbmQoewogICAgICAgICAgICAgICAgIm5hbWUiOiBmLm5hbWUsCiAgICAgICAgICAgICAgICAic2l6ZSI6IGYuc3RhdCgpLnN0X3NpemUsCiAgICAgICAgICAgICAgICAibW9kaWZpZWQiOiBkYXRldGltZS5mcm9tdGltZXN0YW1wKGYuc3RhdCgpLnN0X210aW1lKS5pc29mb3JtYXQoKSwKICAgICAgICAgICAgfSkKCiAgICAjIEV2YWwgcmVzdWx0cwogICAgZXZhbF9wYXRoID0gY2twdF9kaXIgLyAiZXZhbF9yZXN1bHRzLmpzb24iCiAgICBldmFsX3Jlc3VsdHMgPSBOb25lCiAgICBpZiBldmFsX3BhdGguZXhpc3RzKCk6CiAgICAgICAgdHJ5OgogICAgICAgICAgICB3aXRoIG9wZW4oZXZhbF9wYXRoLCAiciIpIGFzIGY6CiAgICAgICAgICAgICAgICBldmFsX3Jlc3VsdHMgPSBqc29uLmxvYWQoZikKICAgICAgICBleGNlcHQgRXhjZXB0aW9uOgogICAgICAgICAgICBwYXNzCgogICAgcmV0dXJuIHsKICAgICAgICAiZGVjaXNpb25GaWxlcyI6IGRlY2lzaW9uX2ZpbGVzLAogICAgICAgICJ0b3RhbERlY2lzaW9ucyI6IHN1bShkWyJkZWNpc2lvbnMiXSBmb3IgZCBpbiBkZWNpc2lvbl9maWxlcyksCiAgICAgICAgImRhdGFzZXRzIjogZGF0YXNldHMsCiAgICAgICAgImNoZWNrcG9pbnRzIjogY2hlY2twb2ludHMsCiAgICAgICAgImV2YWxSZXN1bHRzIjogZXZhbF9yZXN1bHRzLAogICAgICAgICJwb2xpY3lMb2FkZWQiOiBfcG9saWN5X3NlcnZp' +
'Y2UgaXMgbm90IE5vbmUgYW5kIF9wb2xpY3lfc2VydmljZS5fbG9hZGVkIGlmIF9wb2xpY3lfc2VydmljZSBlbHNlIEZhbHNlLAogICAgfQoKCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCiMgUFBPIFRyYWluaW5nICsgVG91cm5hbWVudCBFbmRwb2ludHMKIyDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZAKCl9wcG9fc3RhdGUgPSB7CiAgICAicnVubmluZyI6IEZhbHNlLAogICAgIml0ZXJhdGlvbiI6IDAsCiAgICAidG90YWxfaXRlcmF0aW9ucyI6IDAsCiAgICAicGhhc2UiOiAiaWRsZSIsCiAgICAibWVzc2FnZSI6ICIiLAogICAgIm1ldHJpY3MiOiBOb25lLAogICAgInJlc3VsdCI6IE5vbmUsCiAgICAiZXJyb3IiOiBOb25lLAp9CgpfdG91cm5hbWVudF9zdGF0ZSA9IHsKICAgICJydW5uaW5nIjogRmFsc2UsCiAgICAicGhhc2UiOiAiaWRsZSIsCiAgICAibWVzc2FnZSI6ICIiLAogICAgInJlc3VsdCI6IE5vbmUsCiAgICAiZXJyb3IiOiBOb25lLAp9CgoKZGVmIF9ydW5fcHBvX3BpcGVsaW5lKAogICAgaXRlcmF0aW9uczogaW50LCBlcGlzb2Rlc19wZXJfaXRlcjogaW50LCBwcG9fZXBvY2hzOiBpbnQsIGJhdGNoX3NpemU6IGludCwKICAgIGxyOiBmbG9hdCwgY2xpcF9lcHNpbG9uOiBmbG9hdCwgZW50cm9weV9jb2VmZjogZmxvYXQsCiAgICBvcHBvbmVudDogc3RyLCBwbGF5c3R5bGU6IHN0ciwgbG9hZF9zdXBlcnZpc2VkOiBzdHIsCik6CiAgICAiIiJSdW4gUFBPIHRyYWluaW5nIGluIGEgYmFja2dyb3VuZCB0aHJlYWQuIiIiCiAgICBnbG9iYWwgX3Bwb19zdGF0ZQogICAgdHJ5OgogICAgICAgIGltcG9ydCBzeXMKICAgICAgICBwcm9qZWN0X3Jvb3QgPSBzdHIoUGF0aChfX2ZpbGVfXykucGFyZW50KQogICAgICAgIGlmIHByb2plY3Rfcm9vdCBub3QgaW4gc3lzLnBhdGg6CiAgICAgICAgICAgIHN5cy5wYXRoLmluc2VydCgwLCBwcm9qZWN0X3Jvb3QpCgogICAgICAgIF9wcG9fc3RhdGVbInJ1bm5pbmciXSA9IFRydWUKICAgICAgICBfcHBvX3N0YXRlWyJlcnJvciJdID0gTm9uZQogICAgICAgIF9wcG9fc3RhdGVbInJlc3VsdCJdID0gTm9uZQoKICAgICAgICBmcm9tIG1sLnRyYWluaW5nLnBwb190cmFpbmVyIGltcG9ydCBQUE9UcmFpbmVyLCBQUE9Db25maWcKCiAgICAgICAgY2twdF9kaXIgPSBvcy5wYXRoLmpvaW4ocHJvamVjdF9yb290LCAibWwiLCAibW9kZWxzIiwgImNoZWNrcG9pbnRzIikKICAgICAgICBjb25maWcgPSBQUE9Db25maWcoCiAgICAgICAgICAgIGl0ZXJhdGlvbnM9aXRlcmF0aW9ucywKICAgICAgICAgICAgZXBpc29kZXNfcGVyX2l0ZXI9ZXBpc29kZXNfcGVyX2l0ZXIsCiAgICAgICAgICAgIHBwb19lcG9jaHM9cHBvX2Vwb2NocywKICAgICAgICAgICAgYmF0Y2hfc2l6ZT1iYXRjaF9zaXplLAogICAgICAgICAgICBsZWFybmluZ19yYXRlPWxyLAogICAgICAgICAgICBjbGlwX2Vwc2lsb249Y2xpcF9lcHNpbG9uLAogICAgICAgICAgICBlbnRyb3B5X2NvZWZmPWVudHJvcHlfY29lZmYsCiAgICAgICAgICAgIG9wcG9uZW50PW9wcG9uZW50LAogICAgICAgICAgICBwbGF5c3R5bGU9cGxheXN0eWxlLAogICAgICAgICAgICBjaGVja3BvaW50X2Rpcj1ja3B0X2RpciwKICAgICAgICAgICAgbG9hZF9zdXBlcnZpc2VkPWxvYWRfc3VwZXJ2aXNlZCBpZiBsb2FkX3N1cGVydmlzZWQgZWxzZSBOb25lLAogICAgICAgICkKCiAgICAgICAgdHJhaW5lciA9IFBQT1RyYWluZXIoY29uZmlnKQoKICAgICAgICBkZWYgcHJvZ3Jlc3NfY2IoaXRlcmF0aW9uLCBtZXRyaWNzKToKICAgICAgICAgICAgX3Bwb19zdGF0ZVsiaXRlcmF0aW9uIl0gPSBpdGVyYXRpb24KICAgICAgICAgICAgX3Bwb19zdGF0ZVsicGhhc2UiXSA9ICJ0cmFpbmluZyIKICAgICAgICAgICAgX3Bwb19zdGF0ZVsibWVzc2FnZSJdID0gZiJJdGVyYXRpb24ge2l0ZXJhdGlvbn0ve2l0ZXJhdGlvbnN9' +
'IHwgV1I6IHttZXRyaWNzLmdldCgnd2luX3JhdGUnLCAwKTouMCV9IgogICAgICAgICAgICBfcHBvX3N0YXRlWyJtZXRyaWNzIl0gPSBtZXRyaWNzCgogICAgICAgIHN1bW1hcnkgPSB0cmFpbmVyLnRyYWluKHByb2dyZXNzX2NhbGxiYWNrPXByb2dyZXNzX2NiKQoKICAgICAgICBfcHBvX3N0YXRlWyJwaGFzZSJdID0gImRvbmUiCiAgICAgICAgX3Bwb19zdGF0ZVsicnVubmluZyJdID0gRmFsc2UKICAgICAgICBfcHBvX3N0YXRlWyJyZXN1bHQiXSA9IHN1bW1hcnkKICAgICAgICBfcHBvX3N0YXRlWyJtZXNzYWdlIl0gPSBmIlBQTyBjb21wbGV0ZSEgQmVzdCB3aW4gcmF0ZToge3N1bW1hcnkuZ2V0KCdiZXN0X3dpbl9yYXRlJywgMCk6LjAlfSIKCiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgaW1wb3J0IHRyYWNlYmFjawogICAgICAgIF9wcG9fc3RhdGVbInBoYXNlIl0gPSAiZXJyb3IiCiAgICAgICAgX3Bwb19zdGF0ZVsicnVubmluZyJdID0gRmFsc2UKICAgICAgICBfcHBvX3N0YXRlWyJlcnJvciJdID0gc3RyKGUpCiAgICAgICAgX3Bwb19zdGF0ZVsibWVzc2FnZSJdID0gZiJQUE8gZmFpbGVkOiB7ZX0iCiAgICAgICAgdHJhY2ViYWNrLnByaW50X2V4YygpCgoKQGFwcC5wb3N0KCIvYXBpL21sL3RyYWluL3BwbyIpCmFzeW5jIGRlZiBtbF9zdGFydF9wcG8ocmVxdWVzdDogRmFzdEFQSVJlcXVlc3QpOgogICAgIiIiU3RhcnQgUFBPIHRyYWluaW5nIHBpcGVsaW5lLiIiIgogICAgaWYgX3Bwb19zdGF0ZVsicnVubmluZyJdOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNDA5LCAiUFBPIHRyYWluaW5nIGFscmVhZHkgaW4gcHJvZ3Jlc3MiKQogICAgaWYgX3RyYWluaW5nX3N0YXRlWyJydW5uaW5nIl06CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig0MDksICJTdXBlcnZpc2VkIHRyYWluaW5nIGluIHByb2dyZXNzIOKAlCB3YWl0IGZvciBpdCB0byBmaW5pc2giKQoKICAgIGJvZHkgPSBhd2FpdCByZXF1ZXN0Lmpzb24oKSBpZiBhd2FpdCByZXF1ZXN0LmJvZHkoKSBlbHNlIHt9CiAgICBpdGVyYXRpb25zID0gYm9keS5nZXQoIml0ZXJhdGlvbnMiLCAxMDApCiAgICBlcGlzb2RlcyA9IGJvZHkuZ2V0KCJlcGlzb2Rlc1Blckl0ZXIiLCA2NCkKICAgIHBwb19lcG9jaHMgPSBib2R5LmdldCgicHBvRXBvY2hzIiwgNCkKICAgIGJhdGNoX3NpemUgPSBib2R5LmdldCgiYmF0Y2hTaXplIiwgMjU2KQogICAgbHIgPSBib2R5LmdldCgibHIiLCAzZS00KQogICAgY2xpcF9lcHMgPSBib2R5LmdldCgiY2xpcEVwc2lsb24iLCAwLjIpCiAgICBlbnRyb3B5ID0gYm9keS5nZXQoImVudHJvcHlDb2VmZiIsIDAuMDEpCiAgICBvcHBvbmVudCA9IGJvZHkuZ2V0KCJvcHBvbmVudCIsICJoZXVyaXN0aWMiKQogICAgcGxheXN0eWxlID0gYm9keS5nZXQoInBsYXlzdHlsZSIsICJtaWRyYW5nZSIpCiAgICBsb2FkX3N1cCA9IGJvZHkuZ2V0KCJsb2FkU3VwZXJ2aXNlZCIsICIiKQoKICAgIF9wcG9fc3RhdGUudXBkYXRlKHsKICAgICAgICAicnVubmluZyI6IFRydWUsICJpdGVyYXRpb24iOiAwLCAidG90YWxfaXRlcmF0aW9ucyI6IGl0ZXJhdGlvbnMsCiAgICAgICAgInBoYXNlIjogInN0YXJ0aW5nIiwgIm1lc3NhZ2UiOiAiSW5pdGlhbGl6aW5nIFBQTy4uLiIsCiAgICAgICAgIm1ldHJpY3MiOiBOb25lLCAicmVzdWx0IjogTm9uZSwgImVycm9yIjogTm9uZSwKICAgIH0pCgogICAgdGhyZWFkID0gdGhyZWFkaW5nLlRocmVhZCgKICAgICAgICB0YXJnZXQ9X3J1bl9wcG9fcGlwZWxpbmUsCiAgICAgICAgYXJncz0oaXRlcmF0aW9ucywgZXBpc29kZXMsIHBwb19lcG9jaHMsIGJhdGNoX3NpemUsIGxyLCBjbGlwX2VwcywgZW50cm9weSwgb3Bwb25lbnQsIHBsYXlzdHlsZSwgbG9hZF9zdXApLAogICAgICAgIGRhZW1vbj1UcnVlLAogICAgKQogICAgdGhyZWFkLnN0YXJ0KCkKCiAgICByZXR1cm4geyJzdGF0dXMiOiAic3RhcnRlZCIsICJpdGVyYXRpb25zIjogaXRlcmF0aW9uc30KCgpAYXBwLmdldCgiL2FwaS9tbC90cmFpbi9wcG8vc3RhdHVzIikKYXN5bmMgZGVmIG1sX3Bwb19zdGF0dXMoKToKICAgICIiIkdldCBQUE8gdHJhaW5pbmcgc3RhdHVzLiIiIgogICAgcmV0dXJuIGRpY3QoX3Bwb19zdGF0ZSkKCgpkZWYgX3J1bl90b3VybmFtZW50X3BpcGVsaW5lKGVwaXNvZGVzOiBpbnQsIHBsYXlzdHlsZTogc3RyKToKICAgICIiIlJ1biB0b3VybmFtZW50IGluIGEgYmFja2dyb3VuZCB0aHJlYWQuIiIiCiAgICBnbG9iYWwgX3RvdXJuYW1lbnRfc3RhdGUK' +
'ICAgIHRyeToKICAgICAgICBpbXBvcnQgc3lzCiAgICAgICAgcHJvamVjdF9yb290ID0gc3RyKFBhdGgoX19maWxlX18pLnBhcmVudCkKICAgICAgICBpZiBwcm9qZWN0X3Jvb3Qgbm90IGluIHN5cy5wYXRoOgogICAgICAgICAgICBzeXMucGF0aC5pbnNlcnQoMCwgcHJvamVjdF9yb290KQoKICAgICAgICBfdG91cm5hbWVudF9zdGF0ZVsicnVubmluZyJdID0gVHJ1ZQogICAgICAgIF90b3VybmFtZW50X3N0YXRlWyJlcnJvciJdID0gTm9uZQogICAgICAgIF90b3VybmFtZW50X3N0YXRlWyJyZXN1bHQiXSA9IE5vbmUKICAgICAgICBfdG91cm5hbWVudF9zdGF0ZVsicGhhc2UiXSA9ICJydW5uaW5nIgogICAgICAgIF90b3VybmFtZW50X3N0YXRlWyJtZXNzYWdlIl0gPSAiUnVubmluZyB0b3VybmFtZW50Li4uIgoKICAgICAgICBmcm9tIG1sLmV2YWwudG91cm5hbWVudCBpbXBvcnQgKAogICAgICAgICAgICBydW5fdG91cm5hbWVudCwgSGV1cmlzdGljUG9saWN5LCBSYW5kb21Qb2xpY3ksIExlYXJuZWRQb2xpY3ksCiAgICAgICAgKQoKICAgICAgICBja3B0X2RpciA9IG9zLnBhdGguam9pbihwcm9qZWN0X3Jvb3QsICJtbCIsICJtb2RlbHMiLCAiY2hlY2twb2ludHMiKQogICAgICAgIHBvbGljaWVzID0gewogICAgICAgICAgICAiaGV1cmlzdGljIjogSGV1cmlzdGljUG9saWN5KCksCiAgICAgICAgICAgICJyYW5kb20iOiBSYW5kb21Qb2xpY3koKSwKICAgICAgICB9CgogICAgICAgIHN1cF9wYXRoID0gb3MucGF0aC5qb2luKGNrcHRfZGlyLCAiYmVzdF9wb2xpY3kucHQiKQogICAgICAgIGlmIG9zLnBhdGguZXhpc3RzKHN1cF9wYXRoKToKICAgICAgICAgICAgcG9saWNpZXNbInN1cGVydmlzZWQiXSA9IExlYXJuZWRQb2xpY3koc3VwX3BhdGgpCgogICAgICAgIHBwb19wYXRoID0gb3MucGF0aC5qb2luKGNrcHRfZGlyLCAiYmVzdF9wcG8ucHQiKQogICAgICAgIGlmIG9zLnBhdGguZXhpc3RzKHBwb19wYXRoKToKICAgICAgICAgICAgcG9saWNpZXNbInBwbyJdID0gTGVhcm5lZFBvbGljeShwcG9fcGF0aCkKCiAgICAgICAgcmVzdWx0ID0gcnVuX3RvdXJuYW1lbnQoCiAgICAgICAgICAgIHBvbGljaWVzPXBvbGljaWVzLAogICAgICAgICAgICBlcGlzb2Rlc19wZXJfbWF0Y2h1cD1lcGlzb2RlcywKICAgICAgICAgICAgcGxheXN0eWxlPXBsYXlzdHlsZSwKICAgICAgICApCgogICAgICAgICMgU2F2ZSByZXN1bHRzCiAgICAgICAgb3V0cHV0X3BhdGggPSBvcy5wYXRoLmpvaW4oY2twdF9kaXIsICJ0b3VybmFtZW50X3Jlc3VsdHMuanNvbiIpCiAgICAgICAgd2l0aCBvcGVuKG91dHB1dF9wYXRoLCAidyIpIGFzIGY6CiAgICAgICAgICAgIGpzb24uZHVtcChyZXN1bHQudG9fZGljdCgpLCBmLCBpbmRlbnQ9MikKCiAgICAgICAgX3RvdXJuYW1lbnRfc3RhdGVbInBoYXNlIl0gPSAiZG9uZSIKICAgICAgICBfdG91cm5hbWVudF9zdGF0ZVsicnVubmluZyJdID0gRmFsc2UKICAgICAgICBfdG91cm5hbWVudF9zdGF0ZVsicmVzdWx0Il0gPSByZXN1bHQudG9fZGljdCgpCiAgICAgICAgX3RvdXJuYW1lbnRfc3RhdGVbIm1lc3NhZ2UiXSA9IGYiVG91cm5hbWVudCBjb21wbGV0ZSEge3Jlc3VsdC50b3RhbF9tYXRjaGVzfSBtYXRjaGVzIgoKICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICBpbXBvcnQgdHJhY2ViYWNrCiAgICAgICAgX3RvdXJuYW1lbnRfc3RhdGVbInBoYXNlIl0gPSAiZXJyb3IiCiAgICAgICAgX3RvdXJuYW1lbnRfc3RhdGVbInJ1bm5pbmciXSA9IEZhbHNlCiAgICAgICAgX3RvdXJuYW1lbnRfc3RhdGVbImVycm9yIl0gPSBzdHIoZSkKICAgICAgICBfdG91cm5hbWVudF9zdGF0ZVsibWVzc2FnZSJdID0gZiJUb3VybmFtZW50IGZhaWxlZDoge2V9IgogICAgICAgIHRyYWNlYmFjay5wcmludF9leGMoKQoKCkBhcHAucG9zdCgiL2FwaS9tbC90b3VybmFtZW50IikKYXN5bmMgZGVmIG1sX3N0YXJ0X3RvdXJuYW1lbnQocmVxdWVzdDogRmFzdEFQSVJlcXVlc3QpOgogICAgIiIiU3RhcnQgYSB0b3VybmFtZW50IGV2YWx1YXRpb24uIiIiCiAgICBpZiBfdG91cm5hbWVudF9zdGF0ZVsicnVubmluZyJdOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNDA5LCAiVG91cm5hbWVudCBhbHJlYWR5IGluIHByb2dyZXNzIikKCiAgICBib2R5ID0gYXdhaXQgcmVxdWVzdC5qc29uKCkgaWYgYXdhaXQgcmVxdWVzdC5ib2R5KCkgZWxzZSB7fQogICAgZXBpc29kZXMgPSBib2R5LmdldCgiZXBpc29kZXMiLCA1MCkKICAgIHBsYXlzdHlsZSA9IGJv' +
'ZHkuZ2V0KCJwbGF5c3R5bGUiLCAibWlkcmFuZ2UiKQoKICAgIF90b3VybmFtZW50X3N0YXRlLnVwZGF0ZSh7CiAgICAgICAgInJ1bm5pbmciOiBUcnVlLCAicGhhc2UiOiAic3RhcnRpbmciLAogICAgICAgICJtZXNzYWdlIjogIkluaXRpYWxpemluZyB0b3VybmFtZW50Li4uIiwKICAgICAgICAicmVzdWx0IjogTm9uZSwgImVycm9yIjogTm9uZSwKICAgIH0pCgogICAgdGhyZWFkID0gdGhyZWFkaW5nLlRocmVhZCgKICAgICAgICB0YXJnZXQ9X3J1bl90b3VybmFtZW50X3BpcGVsaW5lLAogICAgICAgIGFyZ3M9KGVwaXNvZGVzLCBwbGF5c3R5bGUpLAogICAgICAgIGRhZW1vbj1UcnVlLAogICAgKQogICAgdGhyZWFkLnN0YXJ0KCkKCiAgICByZXR1cm4geyJzdGF0dXMiOiAic3RhcnRlZCIsICJlcGlzb2RlcyI6IGVwaXNvZGVzfQoKCkBhcHAuZ2V0KCIvYXBpL21sL3RvdXJuYW1lbnQvc3RhdHVzIikKYXN5bmMgZGVmIG1sX3RvdXJuYW1lbnRfc3RhdHVzKCk6CiAgICAiIiJHZXQgdG91cm5hbWVudCBzdGF0dXMuIiIiCiAgICByZXR1cm4gZGljdChfdG91cm5hbWVudF9zdGF0ZSkKCgpAYXBwLmdldCgiL2FwaS9tbC90b3VybmFtZW50L3Jlc3VsdHMiKQphc3luYyBkZWYgbWxfdG91cm5hbWVudF9yZXN1bHRzKCk6CiAgICAiIiJHZXQgbGF0ZXN0IHRvdXJuYW1lbnQgcmVzdWx0cy4iIiIKICAgIHByb2plY3Rfcm9vdCA9IFBhdGgoX19maWxlX18pLnBhcmVudAogICAgcmVzdWx0c19wYXRoID0gcHJvamVjdF9yb290IC8gIm1sIiAvICJtb2RlbHMiIC8gImNoZWNrcG9pbnRzIiAvICJ0b3VybmFtZW50X3Jlc3VsdHMuanNvbiIKICAgIGlmIHJlc3VsdHNfcGF0aC5leGlzdHMoKToKICAgICAgICB3aXRoIG9wZW4ocmVzdWx0c19wYXRoLCAiciIpIGFzIGY6CiAgICAgICAgICAgIHJldHVybiBqc29uLmxvYWQoZikKICAgIHJldHVybiB7ImVycm9yIjogIk5vIHRvdXJuYW1lbnQgcmVzdWx0cyBmb3VuZC4gUnVuIGEgdG91cm5hbWVudCBmaXJzdC4ifQoKCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCiMgU3RhdGljIEZpbGUgU2VydmluZyAoUmVhY3QgU1BBKSDigJQgTVVTVCBiZSBhZnRlciBhbGwgQVBJIHJvdXRlcwojIOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAoKIyBVSSBTZXJ2aW5nOiBsZWdhY3kgSFRNTC9KUy9DU1MgcGFnZXMgKHByaW1hcnkpIG9yIFJlYWN0IFNQQSAoZmFsbGJhY2spCl9sZWdhY3lfdWlfZGlyID0gUGF0aChfX2ZpbGVfXykucGFyZW50IC8gInVpIgpfc3BhX2RpciA9IFBhdGgoX19maWxlX18pLnBhcmVudCAvICJmcm9udGVuZCIgLyAiY29tbWFuZGVyLWFpLWxhYi11aSIgLyAiZGlzdCIKCmlmIF9sZWdhY3lfdWlfZGlyLmV4aXN0cygpOgogICAgIyBQcmltYXJ5OiBzZXJ2ZSB0aGUgcHJvdmVuIG11bHRpLXBhZ2UgSFRNTC9KUy9DU1MgVUkKICAgIGFwcC5tb3VudCgiLyIsIFN0YXRpY0ZpbGVzKGRpcmVjdG9yeT1zdHIoX2xlZ2FjeV91aV9kaXIpLCBodG1sPVRydWUpLCBuYW1lPSJ1aSIpCgplbGlmIF9zcGFfZGlyLmV4aXN0cygpOgogICAgIyBGYWxsYmFjazogUmVhY3QgU1BBIChvbmx5IGlmIGxlZ2FjeSB1aS8gZm9sZGVyIGlzIG1pc3NpbmcpCiAgICBfc3BhX2Fzc2V0cyA9IF9zcGFfZGlyIC8gImFzc2V0cyIKICAgIGlmIF9zcGFfYXNzZXRzLmV4aXN0cygpOgogICAgICAgIGFwcC5tb3VudCgiL2Fzc2V0cyIsIFN0YXRpY0ZpbGVzKGRpcmVjdG9yeT1zdHIoX3NwYV9hc3NldHMpKSwgbmFtZT0ic3BhLWFzc2V0cyIpCgogICAgQGFwcC5nZXQoIi97ZnVsbF9wYXRoOnBhdGh9IikKICAgIGFzeW5jIGRlZiBfc3BhX2NhdGNoYWxsKGZ1bGxfcGF0aDogc3RyKToKICAgICAgICBmcm9tIGZhc3RhcGkucmVzcG9uc2VzIGltcG9ydCBGaWxlUmVzcG9uc2UKICAgICAgICByZXF1ZXN0ZWRfZmlsZSA9IF9zcGFfZGlyIC8gZnVsbF9wYXRoCiAgICAgICAgaWYgZnVsbF9w' +
'YXRoIGFuZCByZXF1ZXN0ZWRfZmlsZS5leGlzdHMoKSBhbmQgcmVxdWVzdGVkX2ZpbGUuaXNfZmlsZSgpOgogICAgICAgICAgICByZXR1cm4gRmlsZVJlc3BvbnNlKHN0cihyZXF1ZXN0ZWRfZmlsZSkpCiAgICAgICAgcmV0dXJuIEZpbGVSZXNwb25zZShzdHIoX3NwYV9kaXIgLyAiaW5kZXguaHRtbCIpKQoKCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCiMgU3RhcnR1cAojIOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAoKZGVmIHBhcnNlX2FyZ3MoKToKICAgIHBhcnNlciA9IGFyZ3BhcnNlLkFyZ3VtZW50UGFyc2VyKGRlc2NyaXB0aW9uPSJDb21tYW5kZXIgQUkgTGFiIEFQSSBTZXJ2ZXIgdjMiKQogICAgcGFyc2VyLmFkZF9hcmd1bWVudCgiLS1mb3JnZS1qYXIiLCBkZWZhdWx0PW9zLmVudmlyb24uZ2V0KCJGT1JHRV9KQVIiLCAiIiksCiAgICAgICAgICAgICAgICAgICAgICAgIGhlbHA9IlBhdGggdG8gZm9yZ2UtZ3VpLWRlc2t0b3AgamFyLXdpdGgtZGVwZW5kZW5jaWVzIikKICAgIHBhcnNlci5hZGRfYXJndW1lbnQoIi0tZm9yZ2UtZGlyIiwgZGVmYXVsdD1vcy5lbnZpcm9uLmdldCgiRk9SR0VfRElSIiwgIiIpLAogICAgICAgICAgICAgICAgICAgICAgICBoZWxwPSJGb3JnZSB3b3JraW5nIGRpcmVjdG9yeSAoY29udGFpbnMgcmVzLykiKQogICAgcGFyc2VyLmFkZF9hcmd1bWVudCgiLS1mb3JnZS1kZWNrcy1kaXIiLCBkZWZhdWx0PW9zLmVudmlyb24uZ2V0KCJGT1JHRV9ERUNLU19ESVIiLCAiIiksCiAgICAgICAgICAgICAgICAgICAgICAgIGhlbHA9IlBhdGggdG8gQ29tbWFuZGVyIGRlY2sgZmlsZXMgKGRlZmF1bHQ6ICUlQVBQREFUQSUlL0ZvcmdlL2RlY2tzL2NvbW1hbmRlcikiKQogICAgcGFyc2VyLmFkZF9hcmd1bWVudCgiLS1sYWItamFyIiwgZGVmYXVsdD1vcy5lbnZpcm9uLmdldCgiTEFCX0pBUiIsICIiKSwKICAgICAgICAgICAgICAgICAgICAgICAgaGVscD0iUGF0aCB0byBjb21tYW5kZXItYWktbGFiLmphciAoZGVmYXVsdDogYXV0by1kZXRlY3QgZnJvbSB0YXJnZXQvKSIpCiAgICBwYXJzZXIuYWRkX2FyZ3VtZW50KCItLXBvcnQiLCB0eXBlPWludCwgZGVmYXVsdD1pbnQob3MuZW52aXJvbi5nZXQoIkxBQl9QT1JUIiwgIjgwODAiKSkpCiAgICBwYXJzZXIuYWRkX2FyZ3VtZW50KCItLXhpbWlsYXIta2V5IiwgZGVmYXVsdD1vcy5lbnZpcm9uLmdldCgiWElNSUxBUl9BUElfS0VZIiwgIjk2YzdkYWIzNWRkYmQ4ODI5YjA0YzBmNWJjZWE1N2Y1ZWRlMjA0OTYiKSwKICAgICAgICAgICAgICAgICAgICAgICAgaGVscD0iWGltaWxhciBBUEkga2V5IGZvciBjYXJkIHNjYW5uZXIgKHZpc3VhbCBBSSByZWNvZ25pdGlvbikiKQogICAgcGFyc2VyLmFkZF9hcmd1bWVudCgiLS1wcGx4LWtleSIsIGRlZmF1bHQ9b3MuZW52aXJvbi5nZXQoIlBQTFhfQVBJX0tFWSIsICJwcGx4LUc3NkhnckFVOEltNzJiRVRNZXlSNGFzQVd3dEc4d3JteWZ5NlZTS0E5RFRuMDVGcSIpLAogICAgICAgICAgICAgICAgICAgICAgICBoZWxwPSJQZXJwbGV4aXR5IEFQSSBrZXkgZm9yIEFJIGRlY2sgcmVzZWFyY2gvZ2VuZXJhdGlvbiAoZW52OiBQUExYX0FQSV9LRVkpIikKICAgIHJldHVybiBwYXJzZXIucGFyc2VfYXJncygpCgoKZGVmIHJlc29sdmVfbGFiX2phcigpIC0+IHN0cjoKICAgIHRhcmdldF9kaXIgPSBQYXRoKF9fZmlsZV9fKS5wYXJlbnQgLyAidGFyZ2V0IgogICAgaWYgdGFyZ2V0X2Rpci5leGlzdHMoKToKICAgICAgICBmb3IgcGF0dGVybiBpbiBbCiAgICAgICAgICAgICJjb21tYW5kZXItYWktbGFiLSotamFyLXdpdGgtZGVwZW5kZW5jaWVzLmphciIsCiAgICAgICAgICAgICJjb21tYW5kZXItYWktbGFiLSotc2hhZGVkLmphciIsCiAgICAgICAgICAgICJjb21tYW5kZXItYWktbGFiLSouamFyIiwKICAgICAgICBdOgogICAgICAgICAgICBqYXJzID0gc29ydGVkKHRhcmdldF9k' +
'aXIuZ2xvYihwYXR0ZXJuKSkKICAgICAgICAgICAgamFycyA9IFtqIGZvciBqIGluIGphcnMgaWYgbm90IGoubmFtZS5zdGFydHN3aXRoKCJvcmlnaW5hbC0iKV0KICAgICAgICAgICAgaWYgamFyczoKICAgICAgICAgICAgICAgIHJldHVybiBzdHIoamFyc1swXSkKICAgIHJldHVybiAiIgoKCmRlZiByZXNvbHZlX2ZvcmdlX2RlY2tzX2RpcigpIC0+IHN0cjoKICAgIGlmIHN5cy5wbGF0Zm9ybSA9PSAid2luMzIiOgogICAgICAgIGFwcGRhdGEgPSBvcy5lbnZpcm9uLmdldCgiQVBQREFUQSIsICIiKQogICAgICAgIGlmIGFwcGRhdGE6CiAgICAgICAgICAgIGNhbmRpZGF0ZSA9IG9zLnBhdGguam9pbihhcHBkYXRhLCAiRm9yZ2UiLCAiZGVja3MiLCAiY29tbWFuZGVyIikKICAgICAgICAgICAgaWYgb3MucGF0aC5pc2RpcihjYW5kaWRhdGUpOgogICAgICAgICAgICAgICAgcmV0dXJuIGNhbmRpZGF0ZQogICAgaG9tZSA9IFBhdGguaG9tZSgpCiAgICBmb3IgY2FuZGlkYXRlIGluIFsKICAgICAgICBob21lIC8gIi5mb3JnZSIgLyAiZGVja3MiIC8gImNvbW1hbmRlciIsCiAgICAgICAgaG9tZSAvICJGb3JnZSIgLyAiZGVja3MiIC8gImNvbW1hbmRlciIsCiAgICBdOgogICAgICAgIGlmIGNhbmRpZGF0ZS5pc19kaXIoKToKICAgICAgICAgICAgcmV0dXJuIHN0cihjYW5kaWRhdGUpCiAgICByZXR1cm4gIiIKCgpkZWYgbG9hZF9jb21tYW5kZXJfbWV0YSgpOgogICAgIiIiTG9hZCBjb21tYW5kZXIgbWV0YSBtYXBwaW5nIGZyb20gZmlsZSBvciB1c2UgYnVpbHRpbnMuIiIiCiAgICBnbG9iYWwgQ09NTUFOREVSX01FVEEKICAgIG1ldGFfcGF0aCA9IFBhdGgoX19maWxlX18pLnBhcmVudCAvICJjb21tYW5kZXItbWV0YS5qc29uIgogICAgaWYgbWV0YV9wYXRoLmV4aXN0cygpOgogICAgICAgIHRyeToKICAgICAgICAgICAgd2l0aCBvcGVuKG1ldGFfcGF0aCwgInIiLCBlbmNvZGluZz0idXRmLTgiKSBhcyBmOgogICAgICAgICAgICAgICAgQ09NTUFOREVSX01FVEEgPSBqc29uLmxvYWQoZikKICAgICAgICAgICAgcHJpbnQoZiIgIE1ldGE6ICAgICAgICAgTG9hZGVkIHtsZW4oQ09NTUFOREVSX01FVEEpfSBjb21tYW5kZXJzIGZyb20ge21ldGFfcGF0aH0iKQogICAgICAgICAgICByZXR1cm4KICAgICAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgICAgIHByaW50KGYiICBXQVJOSU5HOiBGYWlsZWQgdG8gbG9hZCBjb21tYW5kZXItbWV0YS5qc29uOiB7ZX0iKQoKICAgIENPTU1BTkRFUl9NRVRBID0gQlVJTFRJTl9DT01NQU5ERVJTCiAgICBwcmludChmIiAgTWV0YTogICAgICAgICB7bGVuKENPTU1BTkRFUl9NRVRBKX0gYnVpbHQtaW4gY29tbWFuZGVycyIpCgoKZGVmIG1haW4oKToKICAgIGFyZ3MgPSBwYXJzZV9hcmdzKCkKCiAgICBDRkcuZm9yZ2VfamFyID0gYXJncy5mb3JnZV9qYXIKICAgIENGRy5mb3JnZV9kaXIgPSBhcmdzLmZvcmdlX2RpcgogICAgQ0ZHLmZvcmdlX2RlY2tzX2RpciA9IGFyZ3MuZm9yZ2VfZGVja3NfZGlyIG9yIHJlc29sdmVfZm9yZ2VfZGVja3NfZGlyKCkKICAgIENGRy5sYWJfamFyID0gYXJncy5sYWJfamFyIG9yIHJlc29sdmVfbGFiX2phcigpCiAgICBDRkcucG9ydCA9IGFyZ3MucG9ydAogICAgQ0ZHLnhpbWlsYXJfYXBpX2tleSA9IGFyZ3MueGltaWxhcl9rZXkKICAgIENGRy5wcGx4X2FwaV9rZXkgPSBhcmdzLnBwbHhfa2V5CgogICAgcHJpbnQoIuKVlOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVlyIpCiAgICBwcmludCgi4pWRICAgICAgQ29tbWFuZGVyIEFJIExhYiDigJQgQVBJIFNlcnZlciAgdjMuMC4wICAgICAg4pWRIikKICAgIHByaW50KCLilZrilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZ0iKQogICAgcHJpbnQoKQogICAgcHJpbnQoZiIgIEZvcmdlIEpBUjogICAge0NGRy5mb3JnZV9qYXJ9IikKICAgIHByaW50KGYiICBGb3JnZSBEaXI6ICAgIHtDRkcuZm9yZ2VfZGlyfSIpCiAgICBwcmludChmIiAgRGVja3MgRGlyOiAgICB7Q0ZHLmZvcmdlX2RlY2tzX2Rp' +
'cn0iKQogICAgcHJpbnQoZiIgIExhYiBKQVI6ICAgICAge0NGRy5sYWJfamFyfSIpCiAgICBwcmludChmIiAgUmVzdWx0cyBEaXI6ICB7Q0ZHLnJlc3VsdHNfZGlyfSIpCiAgICBwcmludChmIiAgUG9ydDogICAgICAgICB7Q0ZHLnBvcnR9IikKICAgIHByaW50KGYiICBYaW1pbGFyOiAgICAgIHsnY29uZmlndXJlZCcgaWYgQ0ZHLnhpbWlsYXJfYXBpX2tleSBlbHNlICdOT1QgU0VUIChzY2FubmVyIHdpbGwgZmFpbCknfSIpCiAgICBwcmludChmIiAgUGVycGxleGl0eTogICB7J2NvbmZpZ3VyZWQnIGlmIENGRy5wcGx4X2FwaV9rZXkgZWxzZSAnTk9UIFNFVCAoQUkgcmVzZWFyY2gvZ2VuIGRpc2FibGVkKSd9IikKICAgIGoxNyA9IGdldF9qYXZhMTcoKQogICAgcHJpbnQoZiIgIEphdmEgMTc6ICAgICAge2oxNyBpZiBqMTcgIT0gJ2phdmEnIGVsc2UgJ05PVCBGT1VORCAoYmF0Y2ggc2ltIG1heSBmYWlsIG9uIEphdmEgMjUrKSd9IikKICAgIHByaW50KGYiICBMTSBTdHVkaW86ICAgIGh0dHA6Ly8xOTIuMTY4LjAuMTIyOjEyMzQiKQoKICAgIGxvYWRfY29tbWFuZGVyX21ldGEoKQogICAgZG93bmxvYWRfcHJlY29uX2RhdGFiYXNlKCkgICMgQXV0by1kb3dubG9hZHMgYWxsIDE2MysgQ29tbWFuZGVyIHByZWNvbnMgb24gZmlyc3QgcnVuCiAgICBpbml0X2NvbGxlY3Rpb25fZGIoKQogICAgaW5pdF9jb2FjaF9zZXJ2aWNlKCkKCiAgICAjIFhpbWlsYXIgQVBJIGtleSBjaGVjawogICAgaWYgbm90IENGRy54aW1pbGFyX2FwaV9rZXk6CiAgICAgICAgcHJpbnQoIiAgV0FSTklORzogLS14aW1pbGFyLWtleSBub3Qgc2V0LiBDYXJkIHNjYW5uZXIgd2lsbCBub3Qgd29yay4iKQogICAgICAgIHByaW50KCIgICAgICAgICAgIFNldCB2aWEgQ0xJOiAtLXhpbWlsYXIta2V5IFlPVVJfS0VZIikKICAgICAgICBwcmludCgiICAgICAgICAgICBPciBlbnYgdmFyOiAgWElNSUxBUl9BUElfS0VZPVlPVVJfS0VZIikKCiAgICBpZiBub3QgQ0ZHLmZvcmdlX2phcjoKICAgICAgICBwcmludCgiV0FSTklORzogLS1mb3JnZS1qYXIgbm90IHNldC4gL2FwaS9sYWIvc3RhcnQgd2lsbCBmYWlsLiIpCiAgICBpZiBub3QgQ0ZHLmxhYl9qYXI6CiAgICAgICAgcHJpbnQoIldBUk5JTkc6IExhYiBKQVIgbm90IGZvdW5kLiBCdWlsZCB3aXRoOiBtdm4gcGFja2FnZSAtRHNraXBUZXN0cyIpCiAgICBpZiBub3QgQ0ZHLmZvcmdlX2RlY2tzX2RpcjoKICAgICAgICBwcmludCgiV0FSTklORzogQ29tbWFuZGVyIGRlY2tzIGRpciBub3QgZm91bmQuIC9hcGkvbGFiL2RlY2tzIHdpbGwgcmV0dXJuIGVtcHR5LiIpCgogICAgcHJpbnQoKQogICAgcHJpbnQoZiIgIFN0YXJ0aW5nIHNlcnZlciBvbiBodHRwOi8vbG9jYWxob3N0OntDRkcucG9ydH0iKQogICAgaWYgX3NwYV9kaXIuZXhpc3RzKCk6CiAgICAgICAgcHJpbnQoZiIgIFdlYiBVSTogICAgICAgaHR0cDovL2xvY2FsaG9zdDp7Q0ZHLnBvcnR9LyAgKFJlYWN0IFNQQSkiKQogICAgICAgIHByaW50KGYiICBSb3V0ZXM6ICAgICAgIC8gKEJhdGNoIFNpbSksIC9jb2xsZWN0aW9uLCAvZGVja3MsIC9hdXRvZ2VuLCAvc2ltdWxhdG9yLCAvY29hY2gsIC90cmFpbmluZyIpCiAgICBlbHNlOgogICAgICAgIHByaW50KGYiICBXZWIgVUk6ICAgICAgIGh0dHA6Ly9sb2NhbGhvc3Q6e0NGRy5wb3J0fS9pbmRleC5odG1sICAobGVnYWN5IEhUTUwpIikKICAgICAgICBwcmludChmIiAgTk9URTogUmVhY3QgU1BBIG5vdCBidWlsdC4gUnVuOiBjZCBmcm9udGVuZC9jb21tYW5kZXItYWktbGFiLXVpICYmIG5wbSBpbnN0YWxsICYmIG5wbSBydW4gYnVpbGQiKQogICAgcHJpbnQoZiIgIEFQSSBkb2NzOiAgICAgaHR0cDovL2xvY2FsaG9zdDp7Q0ZHLnBvcnR9L2RvY3MiKQogICAgcHJpbnQoKQoKICAgIGltcG9ydCB1dmljb3JuCiAgICB1dmljb3JuLnJ1bihhcHAsIGhvc3Q9IjAuMC4wLjAiLCBwb3J0PUNGRy5wb3J0LCBsb2dfbGV2ZWw9ImluZm8iKQoKCmlmIF9fbmFtZV9fID09ICJfX21haW5fXyI6CiAgICBtYWluKCkK'
'dWlsZGVyIERCKS4iIiIKICAgIGNvbm4gPSBfZ2V0X2RiX2Nvbm4oKQogICAgcm93cyA9IGNvbm4uZXhlY3V0ZSgKICAgICAgICAiIiJTRUxFQ1QgZC5pZCBhcyBkZWNrX2lkLCBkLm5hbWUgYXMgZGVja19uYW1lLCBkLmNvbW1hbmRlciwKICAgICAgICAgICAgICAgICAgQ09VTlQoZGMuaWQpIGFzIGNhcmRfY291bnQKICAgICAgICAgICBGUk9NIGRlY2tzIGQKICAgICAgICAgICBMRUZUIEpPSU4gZGVja19jYXJkcyBkYyBPTiBkYy5kZWNrX2lkID0gZC5pZAogICAgICAgICAgIEdST1VQIEJZIGQuaWQKICAgICAgICAgICBPUkRFUiBCWSBkLm5hbWUiIiIKICAgICkuZmV0Y2hhbGwoKQoKICAgICMgQWxzbyBnZXQgcmVwb3J0IGF2YWlsYWJpbGl0eSBmcm9tIGNvYWNoIHNlcnZpY2UKICAgIHJlcG9ydF9pZHMgPSBzZXQoKQogICAgaWYgX2NvYWNoX3NlcnZpY2U6CiAgICAgICAgcmVwb3J0X2lkcyA9IHNldChfY29hY2hfc2VydmljZS5saXN0X2RlY2tfcmVwb3J0cygpKQoKICAgIGRlY2tzID0gW10KICAgIGZvciByIGluIHJvd3M6CiAgICAgICAgZGVja19uYW1lID0gclsiZGVja19uYW1lIl0KICAgICAgICAjIENoZWNrIGlmIGEgcmVwb3J0IGV4aXN0cyAoYnkgZGVjayBuYW1lIHNsdWcgbWF0Y2gpCiAgICAgICAgaGFzX3JlcG9ydCA9IGFueSgKICAgICAgICAgICAgZGVja19uYW1lLmxvd2VyKCkucmVwbGFjZSgiICIsICItIikgPT0gcmlkLmxvd2VyKCkgb3IKICAgICAgICAgICAgZGVja19uYW1lLmxvd2VyKCkgPT0gcmlkLmxvd2VyKCkKICAgICAgICAgICAgZm9yIHJpZCBpbiByZXBvcnRfaWRzCiAgICAgICAgKQogICAgICAgIGRlY2tzLmFwcGVuZCh7CiAgICAgICAgICAgICJkZWNrX2lkIjogclsiZGVja19pZCJdLAogICAgICAgICAgICAiZGVja19uYW1lIjogclsiZGVja19uYW1lIl0sCiAgICAgICAgICAgICJjb21tYW5kZXIiOiByWyJjb21tYW5kZXIiXSBvciAiIiwKICAgICAgICAgICAgImNhcmRfY291bnQiOiByWyJjYXJkX2NvdW50Il0sCiAgICAgICAgICAgICJoYXNfcmVwb3J0IjogaGFzX3JlcG9ydCwKICAgICAgICAgICAgInJlcG9ydF9jb3VudCI6IDEgaWYgaGFzX3JlcG9ydCBlbHNlIDAsCiAgICAgICAgICAgICJsYXN0X3JlcG9ydF9kYXRlIjogTm9uZSwKICAgICAgICB9KQogICAgcmV0dXJuIGRlY2tzCgoKQGFwcC5nZXQoIi9hcGkvY29hY2gvZGVja3Mve2RlY2tfaWR9L3JlcG9ydCIpCmFzeW5jIGRlZiBjb2FjaF9nZXRfcmVwb3J0KGRlY2tfaWQ6IHN0cik6CiAgICAiIiJHZXQgdGhlIGxhdGVzdCBEZWNrUmVwb3J0IGZvciBhIGRlY2suIiIiCiAgICBpZiBfY29hY2hfc2VydmljZSBpcyBOb25lOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNTAwLCAiQ29hY2ggc2VydmljZSBub3QgaW5pdGlhbGl6ZWQiKQogICAgcmVwb3J0ID0gX2NvYWNoX3NlcnZpY2UubG9hZF9kZWNrX3JlcG9ydChkZWNrX2lkKQogICAgaWYgcmVwb3J0IGlzIE5vbmU6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig0MDQsIGYiRGVjayByZXBvcnQgbm90IGZvdW5kOiB7ZGVja19pZH0iKQogICAgcmV0dXJuIHJlcG9ydC5tb2RlbF9kdW1wKCkKCgpjbGFzcyBDb2FjaFJlcXVlc3RCb2R5KEJhc2VNb2RlbCk6CiAgICBnb2FsczogT3B0aW9uYWxbZGljdF0gPSBOb25lCgoKY2xhc3MgQ29hY2hDaGF0TWVzc2FnZShCYXNlTW9kZWwpOgogICAgcm9sZTogc3RyICAjICJ1c2VyIiBvciAiYXNzaXN0YW50IgogICAgY29udGVudDogc3RyCgpjbGFzcyBDb2FjaENoYXRSZXF1ZXN0KEJhc2VNb2RlbCk6CiAgICBkZWNrX2lkOiBzdHIKICAgIG1lc3NhZ2VzOiBsaXN0W2RpY3RdICAjIGNvbnZlcnNhdGlvbiBoaXN0b3J5IFt7cm9sZSwgY29udGVudH1dCiAgICBnb2FsczogT3B0aW9uYWxbZGljdF0gPSBOb25lCiAgICBzdHJlYW06IE9wdGlvbmFsW2Jvb2xdID0gRmFsc2UKCmNsYXNzIENvYWNoQXBwbHlSZXF1ZXN0KEJhc2VNb2RlbCk6CiAgICBzZXNzaW9uX2lkOiBzdHIKICAgIGRlY2tfaWQ6IGludCAgIyBudW1lcmljIGRlY2sgSUQgaW4gdGhlIERCCiAgICBhY2NlcHRlZF9jdXRzOiBsaXN0W3N0cl0gPSBbXSAgIyBjYXJkIG5hbWVzIHRvIHJlbW92ZQogICAgYWNjZXB0ZWRfYWRkczogbGlzdFtzdHJdID0gW10gICMgY2FyZCBuYW1lcyB0byBhZGQKCmNsYXNzIENvYWNoR29hbHNSZXF1ZXN0KEJhc2VNb2RlbCk6CiAgICB0YXJnZXRfcG93ZXJfbGV2ZWw6IE9wdGlvbmFsW2ludF0gPSBOb25lICAjIDEtMTAKICAgIG1ldGFfZm9jdXM6IE9wdGlvbmFsW3N0' +
'cl0gPSBOb25lICAjIGFnZ3JvLCBjb250cm9sLCBjb21ibywgbWlkcmFuZ2UsIHN0YXgKICAgIGJ1ZGdldDogT3B0aW9uYWxbc3RyXSA9IE5vbmUgICMgYnVkZ2V0LCBtZWRpdW0sIG5vLWxpbWl0CiAgICBmb2N1c19hcmVhczogbGlzdFtzdHJdID0gW10gICMgZS5nLiwgWyJyYW1wIiwgImNhcmQgZHJhdyJdCgoKQGFwcC5wb3N0KCIvYXBpL2NvYWNoL2RlY2tzL3tkZWNrX2lkfSIpCmFzeW5jIGRlZiBjb2FjaF9ydW5fc2Vzc2lvbihkZWNrX2lkOiBzdHIsIGJvZHk6IENvYWNoUmVxdWVzdEJvZHkgPSBOb25lKToKICAgICIiIlRyaWdnZXIgYSBjb2FjaGluZyBzZXNzaW9uIGZvciBhIGRlY2suIiIiCiAgICBpZiBfY29hY2hfc2VydmljZSBpcyBOb25lOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNTAwLCAiQ29hY2ggc2VydmljZSBub3QgaW5pdGlhbGl6ZWQiKQoKICAgICMgTG9hZCBlbWJlZGRpbmdzIG9uIGZpcnN0IHVzZSBpZiBub3QgbG9hZGVkCiAgICBpZiBfY29hY2hfZW1iZWRkaW5ncyBhbmQgbm90IF9jb2FjaF9lbWJlZGRpbmdzLmxvYWRlZDoKICAgICAgICB0cnk6CiAgICAgICAgICAgIF9jb2FjaF9lbWJlZGRpbmdzLmxvYWQoZm9yY2VfZG93bmxvYWQ9VHJ1ZSkKICAgICAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgICAgIHByaW50KGYiICBDb2FjaDogRW1iZWRkaW5ncyBkb3dubG9hZCBmYWlsZWQ6IHtlfSIpCgogICAgZ29hbHMgPSBOb25lCiAgICBpZiBib2R5IGFuZCBib2R5LmdvYWxzOgogICAgICAgIGZyb20gY29hY2gubW9kZWxzIGltcG9ydCBDb2FjaEdvYWxzCiAgICAgICAgdHJ5OgogICAgICAgICAgICBnb2FscyA9IENvYWNoR29hbHMoKipib2R5LmdvYWxzKQogICAgICAgIGV4Y2VwdCBFeGNlcHRpb246CiAgICAgICAgICAgIGdvYWxzID0gTm9uZQoKICAgIHRyeToKICAgICAgICBzZXNzaW9uID0gYXdhaXQgX2NvYWNoX3NlcnZpY2UucnVuX2NvYWNoaW5nX3Nlc3Npb24oZGVja19pZCwgZ29hbHMpCiAgICAgICAgcmV0dXJuIHNlc3Npb24ubW9kZWxfZHVtcCgpCiAgICBleGNlcHQgVmFsdWVFcnJvciBhcyBlOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNDA0LCBzdHIoZSkpCiAgICBleGNlcHQgQ29ubmVjdGlvbkVycm9yIGFzIGU6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig1MDMsIGYiTExNIGNvbm5lY3Rpb24gZmFpbGVkOiB7ZX0iKQogICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNTAwLCBmIkNvYWNoIHNlc3Npb24gZmFpbGVkOiB7ZX0iKQoKCkBhcHAuZ2V0KCIvYXBpL2NvYWNoL3Nlc3Npb25zIikKYXN5bmMgZGVmIGNvYWNoX2xpc3Rfc2Vzc2lvbnMoZGVja19pZDogc3RyID0gTm9uZSk6CiAgICAiIiJMaXN0IGFsbCBjb2FjaGluZyBzZXNzaW9ucywgb3B0aW9uYWxseSBmaWx0ZXJlZCBieSBkZWNrLiIiIgogICAgaWYgX2NvYWNoX3NlcnZpY2UgaXMgTm9uZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMCwgIkNvYWNoIHNlcnZpY2Ugbm90IGluaXRpYWxpemVkIikKICAgIHJldHVybiB7InNlc3Npb25zIjogX2NvYWNoX3NlcnZpY2UubGlzdF9zZXNzaW9ucyhkZWNrX2lkKX0KCgpAYXBwLmdldCgiL2FwaS9jb2FjaC9zZXNzaW9ucy97c2Vzc2lvbl9pZH0iKQphc3luYyBkZWYgY29hY2hfZ2V0X3Nlc3Npb24oc2Vzc2lvbl9pZDogc3RyKToKICAgICIiIkdldCBhIHNwZWNpZmljIGNvYWNoaW5nIHNlc3Npb24uIiIiCiAgICBpZiBfY29hY2hfc2VydmljZSBpcyBOb25lOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNTAwLCAiQ29hY2ggc2VydmljZSBub3QgaW5pdGlhbGl6ZWQiKQogICAgc2Vzc2lvbiA9IF9jb2FjaF9zZXJ2aWNlLmxvYWRfc2Vzc2lvbihzZXNzaW9uX2lkKQogICAgaWYgc2Vzc2lvbiBpcyBOb25lOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNDA0LCBmIlNlc3Npb24gbm90IGZvdW5kOiB7c2Vzc2lvbl9pZH0iKQogICAgcmV0dXJuIHNlc3Npb24ubW9kZWxfZHVtcCgpCgoKQGFwcC5wb3N0KCIvYXBpL2NvYWNoL2VtYmVkZGluZ3MvZG93bmxvYWQiKQphc3luYyBkZWYgY29hY2hfZG93bmxvYWRfZW1iZWRkaW5ncygpOgogICAgIiIiRG93bmxvYWQgYW5kIGNvbnZlcnQgTVRHIGNhcmQgZW1iZWRkaW5ncyBmcm9tIEh1Z2dpbmdGYWNlLiIiIgogICAgaWYgX2NvYWNoX2VtYmVkZGluZ3MgaXMgTm9uZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMCwgIkNvYWNoIHNlcnZpY2Ugbm90' +
'IGluaXRpYWxpemVkIikKICAgIHRyeToKICAgICAgICBfY29hY2hfZW1iZWRkaW5ncy5sb2FkKGZvcmNlX2Rvd25sb2FkPVRydWUpCiAgICAgICAgcmV0dXJuIHsKICAgICAgICAgICAgInN1Y2Nlc3MiOiBUcnVlLAogICAgICAgICAgICAiY2FyZHMiOiBfY29hY2hfZW1iZWRkaW5ncy5jYXJkX2NvdW50LAogICAgICAgICAgICAibWVzc2FnZSI6IGYiTG9hZGVkIHtfY29hY2hfZW1iZWRkaW5ncy5jYXJkX2NvdW50fSBjYXJkIGVtYmVkZGluZ3MiCiAgICAgICAgfQogICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNTAwLCBmIkZhaWxlZCB0byBkb3dubG9hZCBlbWJlZGRpbmdzOiB7ZX0iKQoKCkBhcHAuZ2V0KCIvYXBpL2NvYWNoL2VtYmVkZGluZ3Mvc2VhcmNoIikKYXN5bmMgZGVmIGNvYWNoX3NlYXJjaF9zaW1pbGFyKGNhcmQ6IHN0ciwgY29sb3JzOiBzdHIgPSBOb25lLCB0b3BfbjogaW50ID0gMTApOgogICAgIiIiU2VhcmNoIGZvciBzaW1pbGFyIGNhcmRzIGJ5IG5hbWUuIiIiCiAgICBpZiBfY29hY2hfZW1iZWRkaW5ncyBpcyBOb25lIG9yIG5vdCBfY29hY2hfZW1iZWRkaW5ncy5sb2FkZWQ6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig1MDMsICJFbWJlZGRpbmdzIG5vdCBsb2FkZWQiKQogICAgY29sb3JfZmlsdGVyID0gbGlzdChjb2xvcnMudXBwZXIoKSkgaWYgY29sb3JzIGVsc2UgTm9uZQogICAgbWF0Y2hlcyA9IF9jb2FjaF9lbWJlZGRpbmdzLnNlYXJjaF9zaW1pbGFyKAogICAgICAgIHF1ZXJ5X2NhcmQ9Y2FyZCwgY29sb3JfZmlsdGVyPWNvbG9yX2ZpbHRlciwgdG9wX249dG9wX24KICAgICkKICAgIHJldHVybiB7InF1ZXJ5IjogY2FyZCwgIm1hdGNoZXMiOiBbbS50b19kaWN0KCkgZm9yIG0gaW4gbWF0Y2hlc119CgoKQGFwcC5wb3N0KCIvYXBpL2NvYWNoL2NoYXQiKQphc3luYyBkZWYgY29hY2hfY2hhdChib2R5OiBDb2FjaENoYXRSZXF1ZXN0KToKICAgICIiIk11bHRpLXR1cm4gY29hY2hpbmcgY2hhdC4gU2VuZHMgY29udmVyc2F0aW9uIGhpc3RvcnkgdG8gTExNIGFuZCByZXR1cm5zIHJlc3BvbnNlLiIiIgogICAgaWYgX2NvYWNoX3NlcnZpY2UgaXMgTm9uZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMCwgIkNvYWNoIHNlcnZpY2Ugbm90IGluaXRpYWxpemVkIikKCiAgICAjIExvYWQgZGVjayByZXBvcnQgZm9yIGNvbnRleHQKICAgIHJlcG9ydCA9IF9jb2FjaF9zZXJ2aWNlLmxvYWRfZGVja19yZXBvcnQoYm9keS5kZWNrX2lkKQoKICAgICMgQnVpbGQgc3lzdGVtIHByb21wdCB3aXRoIHJlcG9ydCBjb250ZXh0CiAgICBmcm9tIGNvYWNoLnByb21wdF90ZW1wbGF0ZSBpbXBvcnQgYnVpbGRfc3lzdGVtX3Byb21wdAogICAgZnJvbSBjb2FjaC5tb2RlbHMgaW1wb3J0IENvYWNoR29hbHMKCiAgICBnb2FscyA9IE5vbmUKICAgIGlmIGJvZHkuZ29hbHM6CiAgICAgICAgdHJ5OgogICAgICAgICAgICBnb2FscyA9IENvYWNoR29hbHMoKipib2R5LmdvYWxzKQogICAgICAgIGV4Y2VwdCBFeGNlcHRpb246CiAgICAgICAgICAgIGdvYWxzID0gTm9uZQoKICAgIHN5c3RlbV9wcm9tcHQgPSAiIgogICAgaWYgcmVwb3J0OgogICAgICAgIHN5c3RlbV9wcm9tcHQgPSBidWlsZF9zeXN0ZW1fcHJvbXB0KHJlcG9ydCwgZ29hbHMpCiAgICBlbHNlOgogICAgICAgIHN5c3RlbV9wcm9tcHQgPSAoCiAgICAgICAgICAgICJZb3UgYXJlIGFuIGV4cGVydCBNYWdpYzogVGhlIEdhdGhlcmluZyBDb21tYW5kZXIgZGVjayBjb2FjaC4gIgogICAgICAgICAgICAiQW5zd2VyIHF1ZXN0aW9ucyBhYm91dCBkZWNrIGJ1aWxkaW5nLCBzdHJhdGVneSwgY2FyZCBjaG9pY2VzLCBhbmQgZ2FtZSB0aGVvcnkuICIKICAgICAgICAgICAgIkJlIHNwZWNpZmljIGFuZCBhY3Rpb25hYmxlIGluIHlvdXIgYWR2aWNlLiIKICAgICAgICApCgogICAgIyBCdWlsZCBtZXNzYWdlcyBhcnJheSBmb3IgTExNCiAgICBtZXNzYWdlcyA9IFt7InJvbGUiOiAic3lzdGVtIiwgImNvbnRlbnQiOiBzeXN0ZW1fcHJvbXB0fV0KICAgIGZvciBtc2cgaW4gYm9keS5tZXNzYWdlczoKICAgICAgICBtZXNzYWdlcy5hcHBlbmQoeyJyb2xlIjogbXNnLmdldCgicm9sZSIsICJ1c2VyIiksICJjb250ZW50IjogbXNnLmdldCgiY29udGVudCIsICIiKX0pCgogICAgIyBDYWxsIExMTSB3aXRoIG11bHRpLXR1cm4gbWVzc2FnZXMKICAgIHRyeToKICAgICAgICBpbXBvcnQganNvbgogICAgICAgIGZyb20gdXJsbGliLnJlcXVlc3QgaW1wb3J0' +
'IHVybG9wZW4sIFJlcXVlc3QgYXMgVXJsUmVxdWVzdAogICAgICAgIGZyb20gY29hY2guY29uZmlnIGltcG9ydCBMTV9TVFVESU9fVVJMLCBMTV9TVFVESU9fVElNRU9VVAoKICAgICAgICBtb2RlbF9uYW1lID0gX2NvYWNoX2xsbS5fcmVzb2x2ZV9tb2RlbCgpCiAgICAgICAgbGxtX2JvZHkgPSB7CiAgICAgICAgICAgICJtb2RlbCI6IG1vZGVsX25hbWUsCiAgICAgICAgICAgICJtZXNzYWdlcyI6IG1lc3NhZ2VzLAogICAgICAgICAgICAidGVtcGVyYXR1cmUiOiAwLjcsCiAgICAgICAgICAgICJtYXhfdG9rZW5zIjogNDA5NiwKICAgICAgICB9CgogICAgICAgIGlmIGJvZHkuc3RyZWFtOgogICAgICAgICAgICAjIFNTRSBzdHJlYW1pbmcgcmVzcG9uc2UKICAgICAgICAgICAgbGxtX2JvZHlbInN0cmVhbSJdID0gVHJ1ZQoKICAgICAgICAgICAgYXN5bmMgZGVmIGdlbmVyYXRlKCk6CiAgICAgICAgICAgICAgICBpbXBvcnQgYXN5bmNpbwogICAgICAgICAgICAgICAgZGVmIF9zdHJlYW0oKToKICAgICAgICAgICAgICAgICAgICByZXEgPSBVcmxSZXF1ZXN0KAogICAgICAgICAgICAgICAgICAgICAgICBmIntfY29hY2hfbGxtLmJhc2VfdXJsfS9jaGF0L2NvbXBsZXRpb25zIiwKICAgICAgICAgICAgICAgICAgICAgICAgZGF0YT1qc29uLmR1bXBzKGxsbV9ib2R5KS5lbmNvZGUoInV0Zi04IiksCiAgICAgICAgICAgICAgICAgICAgICAgIGhlYWRlcnM9eyJDb250ZW50LVR5cGUiOiAiYXBwbGljYXRpb24vanNvbiJ9LAogICAgICAgICAgICAgICAgICAgICAgICBtZXRob2Q9IlBPU1QiLAogICAgICAgICAgICAgICAgICAgICkKICAgICAgICAgICAgICAgICAgICBpbXBvcnQgaHR0cC5jbGllbnQKICAgICAgICAgICAgICAgICAgICBpbXBvcnQgdXJsbGliLnBhcnNlCiAgICAgICAgICAgICAgICAgICAgcGFyc2VkID0gdXJsbGliLnBhcnNlLnVybHBhcnNlKGYie19jb2FjaF9sbG0uYmFzZV91cmx9L2NoYXQvY29tcGxldGlvbnMiKQogICAgICAgICAgICAgICAgICAgIGNvbm4gPSBodHRwLmNsaWVudC5IVFRQQ29ubmVjdGlvbihwYXJzZWQuaG9zdG5hbWUsIHBhcnNlZC5wb3J0LCB0aW1lb3V0PUxNX1NUVURJT19USU1FT1VUKQogICAgICAgICAgICAgICAgICAgIGNvbm4ucmVxdWVzdCgiUE9TVCIsIHBhcnNlZC5wYXRoLCBib2R5PWpzb24uZHVtcHMobGxtX2JvZHkpLmVuY29kZSgidXRmLTgiKSwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgaGVhZGVycz17IkNvbnRlbnQtVHlwZSI6ICJhcHBsaWNhdGlvbi9qc29uIn0pCiAgICAgICAgICAgICAgICAgICAgcmVzcCA9IGNvbm4uZ2V0cmVzcG9uc2UoKQogICAgICAgICAgICAgICAgICAgIGNodW5rcyA9IFtdCiAgICAgICAgICAgICAgICAgICAgd2hpbGUgVHJ1ZToKICAgICAgICAgICAgICAgICAgICAgICAgbGluZSA9IHJlc3AucmVhZGxpbmUoKQogICAgICAgICAgICAgICAgICAgICAgICBpZiBub3QgbGluZToKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGJyZWFrCiAgICAgICAgICAgICAgICAgICAgICAgIGxpbmUgPSBsaW5lLmRlY29kZSgidXRmLTgiKS5zdHJpcCgpCiAgICAgICAgICAgICAgICAgICAgICAgIGlmIGxpbmUuc3RhcnRzd2l0aCgiZGF0YTogIik6CiAgICAgICAgICAgICAgICAgICAgICAgICAgICBkYXRhID0gbGluZVs2Ol0KICAgICAgICAgICAgICAgICAgICAgICAgICAgIGlmIGRhdGEgPT0gIltET05FXSI6CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgY2h1bmtzLmFwcGVuZCgiW0RPTkVdIikKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBicmVhawogICAgICAgICAgICAgICAgICAgICAgICAgICAgY2h1bmtzLmFwcGVuZChkYXRhKQogICAgICAgICAgICAgICAgICAgIGNvbm4uY2xvc2UoKQogICAgICAgICAgICAgICAgICAgIHJldHVybiBjaHVua3MKCiAgICAgICAgICAgICAgICBsb29wID0gYXN5bmNpby5nZXRfZXZlbnRfbG9vcCgpCiAgICAgICAgICAgICAgICBjaHVua3MgPSBhd2FpdCBsb29wLnJ1bl9pbl9leGVjdXRvcihOb25lLCBfc3RyZWFtKQoKICAgICAgICAgICAgICAgIGZvciBjaHVua19zdHIgaW4gY2h1bmtzOgogICAgICAgICAgICAgICAgICAgIGlmIGNodW5rX3N0ciA9PSAiW0RPTkVdIjoKICAgICAgICAgICAgICAgICAgICAgICAgeWllbGQgZiJkYXRhOiBbRE9ORV1cblxuIgogICAgICAgICAgICAgICAgICAgICAgICBicmVhawogICAgICAgICAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgICAgICAgICAgY2h1' +
'bmsgPSBqc29uLmxvYWRzKGNodW5rX3N0cikKICAgICAgICAgICAgICAgICAgICAgICAgZGVsdGEgPSBjaHVuay5nZXQoImNob2ljZXMiLCBbe31dKVswXS5nZXQoImRlbHRhIiwge30pCiAgICAgICAgICAgICAgICAgICAgICAgIGNvbnRlbnQgPSBkZWx0YS5nZXQoImNvbnRlbnQiLCAiIikKICAgICAgICAgICAgICAgICAgICAgICAgaWYgY29udGVudDoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICMgU3RyaXAgdGhpbmsgdGFncyBmcm9tIHN0cmVhbWluZyBjaHVua3MKICAgICAgICAgICAgICAgICAgICAgICAgICAgIHlpZWxkIGYiZGF0YToge2pzb24uZHVtcHMoeydjb250ZW50JzogY29udGVudH0pfVxuXG4iCiAgICAgICAgICAgICAgICAgICAgZXhjZXB0IGpzb24uSlNPTkRlY29kZUVycm9yOgogICAgICAgICAgICAgICAgICAgICAgICBjb250aW51ZQoKICAgICAgICAgICAgcmV0dXJuIFN0cmVhbWluZ1Jlc3BvbnNlKGdlbmVyYXRlKCksIG1lZGlhX3R5cGU9InRleHQvZXZlbnQtc3RyZWFtIikKICAgICAgICBlbHNlOgogICAgICAgICAgICAjIE5vbi1zdHJlYW1pbmc6IHJlZ3VsYXIgY2FsbAogICAgICAgICAgICByZXEgPSBVcmxSZXF1ZXN0KAogICAgICAgICAgICAgICAgZiJ7X2NvYWNoX2xsbS5iYXNlX3VybH0vY2hhdC9jb21wbGV0aW9ucyIsCiAgICAgICAgICAgICAgICBkYXRhPWpzb24uZHVtcHMobGxtX2JvZHkpLmVuY29kZSgidXRmLTgiKSwKICAgICAgICAgICAgICAgIGhlYWRlcnM9eyJDb250ZW50LVR5cGUiOiAiYXBwbGljYXRpb24vanNvbiJ9LAogICAgICAgICAgICAgICAgbWV0aG9kPSJQT1NUIiwKICAgICAgICAgICAgKQoKICAgICAgICAgICAgaW1wb3J0IGFzeW5jaW8KICAgICAgICAgICAgbG9vcCA9IGFzeW5jaW8uZ2V0X2V2ZW50X2xvb3AoKQogICAgICAgICAgICBkZWYgX2NhbGwoKToKICAgICAgICAgICAgICAgIHdpdGggdXJsb3BlbihyZXEsIHRpbWVvdXQ9TE1fU1RVRElPX1RJTUVPVVQpIGFzIHJlc3A6CiAgICAgICAgICAgICAgICAgICAgcmV0dXJuIGpzb24ubG9hZHMocmVzcC5yZWFkKCkuZGVjb2RlKCJ1dGYtOCIpKQoKICAgICAgICAgICAgcmF3ID0gYXdhaXQgbG9vcC5ydW5faW5fZXhlY3V0b3IoTm9uZSwgX2NhbGwpCgogICAgICAgICAgICBjb250ZW50ID0gcmF3LmdldCgiY2hvaWNlcyIsIFt7fV0pWzBdLmdldCgibWVzc2FnZSIsIHt9KS5nZXQoImNvbnRlbnQiLCAiIikKICAgICAgICAgICAgIyBTdHJpcCB0aGluayB0YWdzCiAgICAgICAgICAgIGNvbnRlbnQgPSBfY29hY2hfbGxtLl9zdHJpcF90aGlua190YWdzKGNvbnRlbnQpCiAgICAgICAgICAgIHVzYWdlID0gcmF3LmdldCgidXNhZ2UiLCB7fSkKCiAgICAgICAgICAgIHJldHVybiB7CiAgICAgICAgICAgICAgICAiY29udGVudCI6IGNvbnRlbnQsCiAgICAgICAgICAgICAgICAibW9kZWwiOiByYXcuZ2V0KCJtb2RlbCIsICIiKSwKICAgICAgICAgICAgICAgICJwcm9tcHRfdG9rZW5zIjogdXNhZ2UuZ2V0KCJwcm9tcHRfdG9rZW5zIiwgMCksCiAgICAgICAgICAgICAgICAiY29tcGxldGlvbl90b2tlbnMiOiB1c2FnZS5nZXQoImNvbXBsZXRpb25fdG9rZW5zIiwgMCksCiAgICAgICAgICAgIH0KCiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig1MDAsIGYiQ2hhdCBmYWlsZWQ6IHtlfSIpCgoKQGFwcC5wb3N0KCIvYXBpL2NvYWNoL2FwcGx5IikKYXN5bmMgZGVmIGNvYWNoX2FwcGx5X3N1Z2dlc3Rpb25zKGJvZHk6IENvYWNoQXBwbHlSZXF1ZXN0KToKICAgICIiIkFwcGx5IGFjY2VwdGVkIGNvYWNoaW5nIHN1Z2dlc3Rpb25zIHRvIGEgZGVjayDigJQgcmVtb3ZlIGN1dHMsIGFkZCBhZGRzLiIiIgogICAgY29ubiA9IF9nZXRfZGJfY29ubigpCgogICAgIyBWZXJpZnkgZGVjayBleGlzdHMKICAgIGRlY2sgPSBjb25uLmV4ZWN1dGUoIlNFTEVDVCAqIEZST00gZGVja3MgV0hFUkUgaWQgPSA/IiwgKGJvZHkuZGVja19pZCwpKS5mZXRjaG9uZSgpCiAgICBpZiBub3QgZGVjazoKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDQwNCwgZiJEZWNrIHtib2R5LmRlY2tfaWR9IG5vdCBmb3VuZCIpCgogICAgcmVzdWx0cyA9IHsiY3V0cyI6IFtdLCAiYWRkcyI6IFtdLCAiZXJyb3JzIjogW119CgogICAgIyBQcm9jZXNzIGN1dHM6IHJlbW92ZSBjYXJkcyBieSBuYW1lCiAgICBmb3IgY2FyZF9uYW1lIGluIGJvZHkuYWNjZXB0ZWRfY3V0czoKICAgICAgICByb3cgPSBjb25uLmV4ZWN1dGUoCiAgICAgICAg' +
'ICAgICJTRUxFQ1QgaWQgRlJPTSBkZWNrX2NhcmRzIFdIRVJFIGRlY2tfaWQgPSA/IEFORCBjYXJkX25hbWUgPSA/IExJTUlUIDEiLAogICAgICAgICAgICAoYm9keS5kZWNrX2lkLCBjYXJkX25hbWUpCiAgICAgICAgKS5mZXRjaG9uZSgpCiAgICAgICAgaWYgcm93OgogICAgICAgICAgICBjb25uLmV4ZWN1dGUoIkRFTEVURSBGUk9NIGRlY2tfY2FyZHMgV0hFUkUgaWQgPSA/IiwgKHJvd1siaWQiXSwpKQogICAgICAgICAgICByZXN1bHRzWyJjdXRzIl0uYXBwZW5kKHsibmFtZSI6IGNhcmRfbmFtZSwgInN0YXR1cyI6ICJyZW1vdmVkIn0pCiAgICAgICAgZWxzZToKICAgICAgICAgICAgIyBUcnkgY2FzZS1pbnNlbnNpdGl2ZQogICAgICAgICAgICByb3cgPSBjb25uLmV4ZWN1dGUoCiAgICAgICAgICAgICAgICAiU0VMRUNUIGlkLCBjYXJkX25hbWUgRlJPTSBkZWNrX2NhcmRzIFdIRVJFIGRlY2tfaWQgPSA/IEFORCBMT1dFUihjYXJkX25hbWUpID0gTE9XRVIoPykgTElNSVQgMSIsCiAgICAgICAgICAgICAgICAoYm9keS5kZWNrX2lkLCBjYXJkX25hbWUpCiAgICAgICAgICAgICkuZmV0Y2hvbmUoKQogICAgICAgICAgICBpZiByb3c6CiAgICAgICAgICAgICAgICBjb25uLmV4ZWN1dGUoIkRFTEVURSBGUk9NIGRlY2tfY2FyZHMgV0hFUkUgaWQgPSA/IiwgKHJvd1siaWQiXSwpKQogICAgICAgICAgICAgICAgcmVzdWx0c1siY3V0cyJdLmFwcGVuZCh7Im5hbWUiOiByb3dbImNhcmRfbmFtZSJdLCAic3RhdHVzIjogInJlbW92ZWQifSkKICAgICAgICAgICAgZWxzZToKICAgICAgICAgICAgICAgIHJlc3VsdHNbImVycm9ycyJdLmFwcGVuZCh7Im5hbWUiOiBjYXJkX25hbWUsICJlcnJvciI6ICJDYXJkIG5vdCBmb3VuZCBpbiBkZWNrIn0pCgogICAgIyBQcm9jZXNzIGFkZHM6IGxvb2sgdXAgaW4gY29sbGVjdGlvbiBmaXJzdCwgdGhlbiBTY3J5ZmFsbAogICAgZm9yIGNhcmRfbmFtZSBpbiBib2R5LmFjY2VwdGVkX2FkZHM6CiAgICAgICAgIyBUcnkgdG8gZmluZCBpbiBjb2xsZWN0aW9uCiAgICAgICAgY2UgPSBjb25uLmV4ZWN1dGUoCiAgICAgICAgICAgICJTRUxFQ1Qgc2NyeWZhbGxfaWQsIG5hbWUgRlJPTSBjb2xsZWN0aW9uX2VudHJpZXMgV0hFUkUgTE9XRVIobmFtZSkgPSBMT1dFUig/KSBMSU1JVCAxIiwKICAgICAgICAgICAgKGNhcmRfbmFtZSwpCiAgICAgICAgKS5mZXRjaG9uZSgpCgogICAgICAgIHNjcnlmYWxsX2lkID0gTm9uZQogICAgICAgIHJlc29sdmVkX25hbWUgPSBjYXJkX25hbWUKCiAgICAgICAgaWYgY2U6CiAgICAgICAgICAgIHNjcnlmYWxsX2lkID0gY2VbInNjcnlmYWxsX2lkIl0KICAgICAgICAgICAgcmVzb2x2ZWRfbmFtZSA9IGNlWyJuYW1lIl0KICAgICAgICBlbHNlOgogICAgICAgICAgICAjIFRyeSBTY3J5ZmFsbCBBUEkgbG9va3VwCiAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgIGltcG9ydCB1cmxsaWIucGFyc2UKICAgICAgICAgICAgICAgIGVuY29kZWQgPSB1cmxsaWIucGFyc2UucXVvdGUoY2FyZF9uYW1lKQogICAgICAgICAgICAgICAgc2NyeV9yZXEgPSBVcmxSZXF1ZXN0KAogICAgICAgICAgICAgICAgICAgIGYiaHR0cHM6Ly9hcGkuc2NyeWZhbGwuY29tL2NhcmRzL25hbWVkP2Z1enp5PXtlbmNvZGVkfSIsCiAgICAgICAgICAgICAgICAgICAgaGVhZGVycz17IlVzZXItQWdlbnQiOiAiY29tbWFuZGVyLWFpLWxhYi8xLjAifQogICAgICAgICAgICAgICAgKQogICAgICAgICAgICAgICAgd2l0aCB1cmxvcGVuKHNjcnlfcmVxLCB0aW1lb3V0PTEwKSBhcyByZXNwOgogICAgICAgICAgICAgICAgICAgIGNhcmRfZGF0YSA9IGpzb24ubG9hZHMocmVzcC5yZWFkKCkuZGVjb2RlKCJ1dGYtOCIpKQogICAgICAgICAgICAgICAgICAgIHNjcnlmYWxsX2lkID0gY2FyZF9kYXRhLmdldCgiaWQiKQogICAgICAgICAgICAgICAgICAgIHJlc29sdmVkX25hbWUgPSBjYXJkX2RhdGEuZ2V0KCJuYW1lIiwgY2FyZF9uYW1lKQogICAgICAgICAgICBleGNlcHQgRXhjZXB0aW9uOgogICAgICAgICAgICAgICAgcGFzcwoKICAgICAgICBpZiBzY3J5ZmFsbF9pZDoKICAgICAgICAgICAgIyBDaGVjayBpZiBhbHJlYWR5IGluIGRlY2sKICAgICAgICAgICAgZXhpc3RpbmcgPSBjb25uLmV4ZWN1dGUoCiAgICAgICAgICAgICAgICAiU0VMRUNUIGlkIEZST00gZGVja19jYXJkcyBXSEVSRSBkZWNrX2lkID0gPyBBTkQgc2NyeWZhbGxfaWQgPSA/IiwKICAgICAgICAgICAgICAgIChib2R5LmRlY2tfaWQsIHNjcnlmYWxsX2lkKQogICAg' +
'ICAgICAgICApLmZldGNob25lKCkKICAgICAgICAgICAgaWYgbm90IGV4aXN0aW5nOgogICAgICAgICAgICAgICAgY29ubi5leGVjdXRlKAogICAgICAgICAgICAgICAgICAgICJJTlNFUlQgSU5UTyBkZWNrX2NhcmRzIChkZWNrX2lkLCBzY3J5ZmFsbF9pZCwgY2FyZF9uYW1lLCBxdWFudGl0eSkgVkFMVUVTICg/LCA/LCA/LCAxKSIsCiAgICAgICAgICAgICAgICAgICAgKGJvZHkuZGVja19pZCwgc2NyeWZhbGxfaWQsIHJlc29sdmVkX25hbWUpCiAgICAgICAgICAgICAgICApCiAgICAgICAgICAgICAgICByZXN1bHRzWyJhZGRzIl0uYXBwZW5kKHsibmFtZSI6IHJlc29sdmVkX25hbWUsICJzY3J5ZmFsbF9pZCI6IHNjcnlmYWxsX2lkLCAic3RhdHVzIjogImFkZGVkIn0pCiAgICAgICAgICAgIGVsc2U6CiAgICAgICAgICAgICAgICByZXN1bHRzWyJhZGRzIl0uYXBwZW5kKHsibmFtZSI6IHJlc29sdmVkX25hbWUsICJzdGF0dXMiOiAiYWxyZWFkeV9pbl9kZWNrIn0pCiAgICAgICAgZWxzZToKICAgICAgICAgICAgcmVzdWx0c1siZXJyb3JzIl0uYXBwZW5kKHsibmFtZSI6IGNhcmRfbmFtZSwgImVycm9yIjogIkNvdWxkIG5vdCByZXNvbHZlIGNhcmQifSkKCiAgICBjb25uLmV4ZWN1dGUoIlVQREFURSBkZWNrcyBTRVQgdXBkYXRlZF9hdCA9IGRhdGV0aW1lKCdub3cnKSBXSEVSRSBpZCA9ID8iLCAoYm9keS5kZWNrX2lkLCkpCiAgICBjb25uLmNvbW1pdCgpCgogICAgcmVzdWx0c1sidG90YWxfY3V0cyJdID0gbGVuKHJlc3VsdHNbImN1dHMiXSkKICAgIHJlc3VsdHNbInRvdGFsX2FkZHMiXSA9IGxlbihbYSBmb3IgYSBpbiByZXN1bHRzWyJhZGRzIl0gaWYgYVsic3RhdHVzIl0gPT0gImFkZGVkIl0pCiAgICByZXR1cm4gcmVzdWx0cwoKCkBhcHAuZ2V0KCIvYXBpL2NvYWNoL2NhcmRzLWxpa2UiKQphc3luYyBkZWYgY29hY2hfY2FyZHNfbGlrZShjYXJkOiBzdHIsIGNvbG9yczogc3RyID0gTm9uZSwgdG9wX246IGludCA9IDEwKToKICAgICIiIkZpbmQgY2FyZHMgc2ltaWxhciB0byBhIGdpdmVuIGNhcmQgdXNpbmcgZW1iZWRkaW5ncy4gVUktZnJpZW5kbHkgdmVyc2lvbi4iIiIKICAgIGlmIF9jb2FjaF9lbWJlZGRpbmdzIGlzIE5vbmUgb3Igbm90IF9jb2FjaF9lbWJlZGRpbmdzLmxvYWRlZDoKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMywgIkVtYmVkZGluZ3Mgbm90IGxvYWRlZCIpCgogICAgY29sb3JfZmlsdGVyID0gbGlzdChjb2xvcnMudXBwZXIoKSkgaWYgY29sb3JzIGVsc2UgTm9uZQogICAgbWF0Y2hlcyA9IF9jb2FjaF9lbWJlZGRpbmdzLnNlYXJjaF9zaW1pbGFyKAogICAgICAgIHF1ZXJ5X2NhcmQ9Y2FyZCwgY29sb3JfZmlsdGVyPWNvbG9yX2ZpbHRlciwgdG9wX249dG9wX24KICAgICkKCiAgICBjb25uID0gX2dldF9kYl9jb25uKCkKICAgIHJlc3VsdHMgPSBbXQogICAgZm9yIG0gaW4gbWF0Y2hlczoKICAgICAgICAjIENoZWNrIGlmIGNhcmQgaXMgaW4gY29sbGVjdGlvbgogICAgICAgIG93bmVkID0gY29ubi5leGVjdXRlKAogICAgICAgICAgICAiU0VMRUNUIFNVTShxdWFudGl0eSkgYXMgcXR5IEZST00gY29sbGVjdGlvbl9lbnRyaWVzIFdIRVJFIExPV0VSKG5hbWUpID0gTE9XRVIoPykiLAogICAgICAgICAgICAobS5uYW1lLCkKICAgICAgICApLmZldGNob25lKCkKICAgICAgICBvd25lZF9xdHkgPSAob3duZWRbInF0eSJdIG9yIDApIGlmIG93bmVkIGVsc2UgMAoKICAgICAgICAjIEdldCBpbWFnZSBVUkwgYW5kIHByaWNlCiAgICAgICAgY2UgPSBjb25uLmV4ZWN1dGUoCiAgICAgICAgICAgICJTRUxFQ1QgaW1hZ2VfdXJsLCB0Y2dfcHJpY2UgRlJPTSBjb2xsZWN0aW9uX2VudHJpZXMgV0hFUkUgTE9XRVIobmFtZSkgPSBMT1dFUig/KSBMSU1JVCAxIiwKICAgICAgICAgICAgKG0ubmFtZSwpCiAgICAgICAgKS5mZXRjaG9uZSgpCgogICAgICAgIHJlc3VsdHMuYXBwZW5kKHsKICAgICAgICAgICAgIm5hbWUiOiBtLm5hbWUsCiAgICAgICAgICAgICJzaW1pbGFyaXR5Ijogcm91bmQobS5zaW1pbGFyaXR5LCA0KSwKICAgICAgICAgICAgInR5cGVzIjogbS50eXBlcywKICAgICAgICAgICAgIm1hbmFfdmFsdWUiOiBtLm1hbmFfdmFsdWUsCiAgICAgICAgICAgICJtYW5hX2Nvc3QiOiBtLm1hbmFfY29zdCwKICAgICAgICAgICAgInRleHQiOiBtLnRleHRbOjIwMF0gaWYgbS50ZXh0IGVsc2UgIiIsCiAgICAgICAgICAgICJvd25lZF9xdHkiOiBvd25lZF9xdHksCiAgICAgICAgICAgICJpbWFnZV91cmwiOiBjZVsiaW1hZ2VfdXJs' +
'Il0gaWYgY2UgZWxzZSBOb25lLAogICAgICAgICAgICAidGNnX3ByaWNlIjogY2VbInRjZ19wcmljZSJdIGlmIGNlIGVsc2UgTm9uZSwKICAgICAgICB9KQoKICAgIHJldHVybiB7InF1ZXJ5IjogY2FyZCwgInJlc3VsdHMiOiByZXN1bHRzfQoKCkBhcHAucG9zdCgiL2FwaS9jb2FjaC9yZXBvcnRzL2dlbmVyYXRlIikKYXN5bmMgZGVmIGNvYWNoX2dlbmVyYXRlX3JlcG9ydHMoKToKICAgICIiIlJlYnVpbGQgYWxsIGRlY2sgcmVwb3J0cyBmcm9tIGJhdGNoIHJlc3VsdCBKU09OcyBpbiByZXN1bHRzLy4iIiIKICAgIHRyeToKICAgICAgICBmcm9tIGNvYWNoLnJlcG9ydF9nZW5lcmF0b3IgaW1wb3J0IGdlbmVyYXRlX2RlY2tfcmVwb3J0cwogICAgICAgIGxhYl9yb290ID0gUGF0aChfX2ZpbGVfXykucGFyZW50CiAgICAgICAgcmVzdWx0c19kaXIgPSBzdHIobGFiX3Jvb3QgLyBDRkcucmVzdWx0c19kaXIpCiAgICAgICAgcmVwb3J0c19kaXIgPSBzdHIobGFiX3Jvb3QgLyAiZGVjay1yZXBvcnRzIikKICAgICAgICB1cGRhdGVkID0gZ2VuZXJhdGVfZGVja19yZXBvcnRzKHJlc3VsdHNfZGlyLCByZXBvcnRzX2RpcikKICAgICAgICByZXR1cm4gewogICAgICAgICAgICAic3RhdHVzIjogIm9rIiwKICAgICAgICAgICAgImRlY2tzVXBkYXRlZCI6IHVwZGF0ZWQsCiAgICAgICAgICAgICJjb3VudCI6IGxlbih1cGRhdGVkKSwKICAgICAgICAgICAgIm1lc3NhZ2UiOiBmIkdlbmVyYXRlZCByZXBvcnRzIGZvciB7bGVuKHVwZGF0ZWQpfSBkZWNrcyIgaWYgdXBkYXRlZCBlbHNlICJObyBiYXRjaCByZXN1bHRzIGZvdW5kIiwKICAgICAgICB9CiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig1MDAsIGYiUmVwb3J0IGdlbmVyYXRpb24gZmFpbGVkOiB7ZX0iKQoKCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCiMgTUwgVHJhaW5pbmcgRGF0YSBFbmRwb2ludHMKIyDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZAKCkBhcHAuZ2V0KCIvYXBpL21sL3N0YXR1cyIpCmFzeW5jIGRlZiBtbF9zdGF0dXMoKToKICAgICIiIkdldCBNTCBkZWNpc2lvbiBsb2dnaW5nIHN0YXR1cyBhbmQgYXZhaWxhYmxlIHRyYWluaW5nIGRhdGEuIiIiCiAgICBnbG9iYWwgX21sX2xvZ2dpbmdfZW5hYmxlZAogICAgbGFiX3Jvb3QgPSBQYXRoKF9fZmlsZV9fKS5wYXJlbnQKICAgIHJlc3VsdHNfZGlyID0gbGFiX3Jvb3QgLyBDRkcucmVzdWx0c19kaXIKCiAgICAjIEZpbmQgZXhpc3RpbmcgTUwgZGVjaXNpb24gZmlsZXMKICAgIG1sX2ZpbGVzID0gW10KICAgIGlmIHJlc3VsdHNfZGlyLmV4aXN0cygpOgogICAgICAgIGZvciBmIGluIHNvcnRlZChyZXN1bHRzX2Rpci5nbG9iKCJtbC1kZWNpc2lvbnMtKi5qc29ubCIpKToKICAgICAgICAgICAgbGluZXMgPSAwCiAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgIHdpdGggb3BlbihmKSBhcyBmaDoKICAgICAgICAgICAgICAgICAgICBsaW5lcyA9IHN1bSgxIGZvciBfIGluIGZoKQogICAgICAgICAgICBleGNlcHQgRXhjZXB0aW9uOgogICAgICAgICAgICAgICAgcGFzcwogICAgICAgICAgICBtbF9maWxlcy5hcHBlbmQoewogICAgICAgICAgICAgICAgImZpbGUiOiBmLm5hbWUsCiAgICAgICAgICAgICAgICAiZGVjaXNpb25zIjogbGluZXMsCiAgICAgICAgICAgICAgICAic2l6ZV9rYiI6IHJvdW5kKGYuc3RhdCgpLnN0X3NpemUgLyAxMDI0LCAxKSwKICAgICAgICAgICAgfSkKCiAgICB0b3RhbF9kZWNpc2lvbnMgPSBzdW0oZlsiZGVjaXNpb25zIl0gZm9yIGYgaW4gbWxfZmlsZXMpCiAgICByZXR1cm4gewogICAgICAgICJtbF9sb2dnaW5nX2VuYWJsZWQiOiBfbWxfbG9nZ2luZ19lbmFibGVkLAogICAgICAgICJ0cmFpbmluZ19maWxlcyI6IG1sX2ZpbGVzLAogICAgICAgICJ0b3RhbF9kZWNpc2lvbnMiOiB0b3RhbF9kZWNp' +
'c2lvbnMsCiAgICAgICAgInRvdGFsX2ZpbGVzIjogbGVuKG1sX2ZpbGVzKSwKICAgIH0KCgpAYXBwLnBvc3QoIi9hcGkvbWwvdG9nZ2xlIikKYXN5bmMgZGVmIG1sX3RvZ2dsZShlbmFibGU6IGJvb2wgPSBUcnVlKToKICAgICIiIkVuYWJsZSBvciBkaXNhYmxlIE1MIGRlY2lzaW9uIGxvZ2dpbmcgZm9yIGZ1dHVyZSBiYXRjaCBydW5zLiIiIgogICAgZ2xvYmFsIF9tbF9sb2dnaW5nX2VuYWJsZWQKICAgIF9tbF9sb2dnaW5nX2VuYWJsZWQgPSBlbmFibGUKICAgIHJldHVybiB7CiAgICAgICAgIm1sX2xvZ2dpbmdfZW5hYmxlZCI6IF9tbF9sb2dnaW5nX2VuYWJsZWQsCiAgICAgICAgIm1lc3NhZ2UiOiBmIk1MIGRlY2lzaW9uIGxvZ2dpbmcgeydlbmFibGVkJyBpZiBlbmFibGUgZWxzZSAnZGlzYWJsZWQnfSBmb3IgZnV0dXJlIGJhdGNoZXMiLAogICAgfQoKCkBhcHAuZ2V0KCIvYXBpL21sL2RlY2lzaW9ucy97ZmlsZW5hbWV9IikKYXN5bmMgZGVmIG1sX2dldF9kZWNpc2lvbnMoZmlsZW5hbWU6IHN0ciwgbGltaXQ6IGludCA9IDEwMCwgb2Zmc2V0OiBpbnQgPSAwKToKICAgICIiIlJlYWQgZGVjaXNpb24gc25hcHNob3RzIGZyb20gYSB0cmFpbmluZyBkYXRhIGZpbGUuIiIiCiAgICBsYWJfcm9vdCA9IFBhdGgoX19maWxlX18pLnBhcmVudAogICAgZmlsZXBhdGggPSBsYWJfcm9vdCAvIENGRy5yZXN1bHRzX2RpciAvIGZpbGVuYW1lCgogICAgaWYgbm90IGZpbGVwYXRoLmV4aXN0cygpIG9yIG5vdCBmaWxlbmFtZS5zdGFydHN3aXRoKCJtbC1kZWNpc2lvbnMtIik6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig0MDQsIGYiTUwgZGVjaXNpb25zIGZpbGUgbm90IGZvdW5kOiB7ZmlsZW5hbWV9IikKCiAgICBkZWNpc2lvbnMgPSBbXQogICAgdHJ5OgogICAgICAgIGltcG9ydCBqc29uIGFzIF9qc29uCiAgICAgICAgd2l0aCBvcGVuKGZpbGVwYXRoKSBhcyBmOgogICAgICAgICAgICBmb3IgaSwgbGluZSBpbiBlbnVtZXJhdGUoZik6CiAgICAgICAgICAgICAgICBpZiBpIDwgb2Zmc2V0OgogICAgICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgICAgICBpZiBsZW4oZGVjaXNpb25zKSA+PSBsaW1pdDoKICAgICAgICAgICAgICAgICAgICBicmVhawogICAgICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgICAgIGRlY2lzaW9ucy5hcHBlbmQoX2pzb24ubG9hZHMobGluZS5zdHJpcCgpKSkKICAgICAgICAgICAgICAgIGV4Y2VwdCBfanNvbi5KU09ORGVjb2RlRXJyb3I6CiAgICAgICAgICAgICAgICAgICAgY29udGludWUKICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKDUwMCwgZiJGYWlsZWQgdG8gcmVhZCBkZWNpc2lvbnM6IHtlfSIpCgogICAgcmV0dXJuIHsKICAgICAgICAiZmlsZSI6IGZpbGVuYW1lLAogICAgICAgICJvZmZzZXQiOiBvZmZzZXQsCiAgICAgICAgImxpbWl0IjogbGltaXQsCiAgICAgICAgImNvdW50IjogbGVuKGRlY2lzaW9ucyksCiAgICAgICAgImRlY2lzaW9ucyI6IGRlY2lzaW9ucywKICAgIH0KCgojIOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAojIE1MIFBvbGljeSBJbmZlcmVuY2UgRW5kcG9pbnRzCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCgojIExhenktbG9hZGVkIHBvbGljeSBpbmZlcmVuY2Ugc2VydmljZSAob25seSBsb2FkcyB3aGVuIGZpcnN0IGNhbGxlZCkKX3BvbGljeV9zZXJ2aWNlID0gTm9uZQpfcG9saWN5X3NlcnZpY2VfaW5pdF9hdHRlbXB0ZWQgPSBGYWxzZQoKCmRlZiBfZ2V0X3BvbGljeV9zZXJ2aWNlKCk6CiAgICAiIiJHZXQgb3IgaW5pdGlhbGl6ZSB0aGUgcG9saWN5IGluZmVyZW5jZSBzZXJ2aWNlIChsYXp5IGxvYWRpbmcpLiIiIgogICAgZ2xvYmFsIF9wb2xpY3lfc2VydmljZSwgX3BvbGljeV9zZXJ2aWNlX2luaXRfYXR0ZW1wdGVkCiAgICBpZiBfcG9saWN5' +
'X3NlcnZpY2UgaXMgTm9uZSBhbmQgbm90IF9wb2xpY3lfc2VydmljZV9pbml0X2F0dGVtcHRlZDoKICAgICAgICBfcG9saWN5X3NlcnZpY2VfaW5pdF9hdHRlbXB0ZWQgPSBUcnVlCiAgICAgICAgdHJ5OgogICAgICAgICAgICBmcm9tIG1sLnNlcnZpbmcucG9saWN5X3NlcnZlciBpbXBvcnQgUG9saWN5SW5mZXJlbmNlU2VydmljZQogICAgICAgICAgICBfcG9saWN5X3NlcnZpY2UgPSBQb2xpY3lJbmZlcmVuY2VTZXJ2aWNlKCkKICAgICAgICAgICAgaWYgX3BvbGljeV9zZXJ2aWNlLmxvYWQoKToKICAgICAgICAgICAgICAgIHByaW50KGYiW01MXSBQb2xpY3kgbW9kZWwgbG9hZGVkIG9uIHtfcG9saWN5X3NlcnZpY2UuZGV2aWNlfSIpCiAgICAgICAgICAgIGVsc2U6CiAgICAgICAgICAgICAgICBwcmludChmIltNTF0gUG9saWN5IG1vZGVsIG5vdCBhdmFpbGFibGU6IHtfcG9saWN5X3NlcnZpY2UuX2xvYWRfZXJyb3J9IikKICAgICAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgICAgIHByaW50KGYiW01MXSBQb2xpY3kgc2VydmljZSBpbml0IGZhaWxlZDoge2V9IikKICAgIHJldHVybiBfcG9saWN5X3NlcnZpY2UKCgpAYXBwLnBvc3QoIi9hcGkvbWwvcHJlZGljdCIpCmFzeW5jIGRlZiBtbF9wcmVkaWN0KHJlcXVlc3Q6IEZhc3RBUElSZXF1ZXN0KToKICAgICIiIlByZWRpY3QgYSBtYWNyby1hY3Rpb24gZnJvbSBhIGdhbWUgc3RhdGUgc25hcHNob3QuCgogICAgQWNjZXB0cyBEZWNpc2lvblNuYXBzaG90LXNoYXBlZCBKU09OIGZyb20gdGhlIEphdmEgYmF0Y2ggcnVubmVyLgogICAgUmV0dXJucyB0aGUgbGVhcm5lZCBwb2xpY3kncyByZWNvbW1lbmRlZCBhY3Rpb24uCgogICAgUmVxdWVzdCBib2R5OgogICAgICAgIHsKICAgICAgICAgICAgInR1cm4iOiA1LAogICAgICAgICAgICAicGhhc2UiOiAibWFpbl8xIiwKICAgICAgICAgICAgImFjdGl2ZV9zZWF0IjogMCwKICAgICAgICAgICAgInBsYXllcnMiOiBbLi4uXSwKICAgICAgICAgICAgImFyY2hldHlwZSI6ICJhZ2dybyIsCiAgICAgICAgICAgICJ0ZW1wZXJhdHVyZSI6IDEuMCwKICAgICAgICAgICAgImdyZWVkeSI6IGZhbHNlCiAgICAgICAgfQoKICAgIFJlc3BvbnNlOgogICAgICAgIHsKICAgICAgICAgICAgImFjdGlvbiI6ICJjYXN0X2NyZWF0dXJlIiwKICAgICAgICAgICAgImFjdGlvbl9pbmRleCI6IDAsCiAgICAgICAgICAgICJjb25maWRlbmNlIjogMC43MywKICAgICAgICAgICAgInByb2JhYmlsaXRpZXMiOiB7Li4ufSwKICAgICAgICAgICAgImluZmVyZW5jZV9tcyI6IDIuMwogICAgICAgIH0KICAgICIiIgogICAgc3ZjID0gX2dldF9wb2xpY3lfc2VydmljZSgpCiAgICBpZiBzdmMgaXMgTm9uZSBvciBub3Qgc3ZjLl9sb2FkZWQ6CiAgICAgICAgZGV0YWlsID0gIlBvbGljeSBtb2RlbCBub3QgbG9hZGVkLiAiCiAgICAgICAgaWYgc3ZjOgogICAgICAgICAgICBkZXRhaWwgKz0gc3ZjLl9sb2FkX2Vycm9yIG9yICJUcmFpbiBhIG1vZGVsIGZpcnN0LiIKICAgICAgICBlbHNlOgogICAgICAgICAgICBkZXRhaWwgKz0gIlB5VG9yY2ggbWF5IG5vdCBiZSBpbnN0YWxsZWQuIgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oc3RhdHVzX2NvZGU9NTAzLCBkZXRhaWw9ZGV0YWlsKQoKICAgIGJvZHkgPSBhd2FpdCByZXF1ZXN0Lmpzb24oKQogICAgcGxheXN0eWxlID0gYm9keS5wb3AoImFyY2hldHlwZSIsICJtaWRyYW5nZSIpCiAgICB0ZW1wZXJhdHVyZSA9IGJvZHkucG9wKCJ0ZW1wZXJhdHVyZSIsIDEuMCkKICAgIGdyZWVkeSA9IGJvZHkucG9wKCJncmVlZHkiLCBGYWxzZSkKCiAgICByZXN1bHQgPSBzdmMucHJlZGljdCgKICAgICAgICBkZWNpc2lvbl9zbmFwc2hvdD1ib2R5LAogICAgICAgIHBsYXlzdHlsZT1wbGF5c3R5bGUsCiAgICAgICAgdGVtcGVyYXR1cmU9dGVtcGVyYXR1cmUsCiAgICAgICAgZ3JlZWR5PWdyZWVkeSwKICAgICkKCiAgICBpZiAiZXJyb3IiIGluIHJlc3VsdDoKICAgICAgICByYWlzZSBIVFRQRXhjZXB0aW9uKHN0YXR1c19jb2RlPTUwMCwgZGV0YWlsPXJlc3VsdFsiZXJyb3IiXSkKICAgIHJldHVybiByZXN1bHQKCgpAYXBwLnBvc3QoIi9hcGkvbWwvcHJlZGljdC9iYXRjaCIpCmFzeW5jIGRlZiBtbF9wcmVkaWN0X2JhdGNoKHJlcXVlc3Q6IEZhc3RBUElSZXF1ZXN0KToKICAgICIiIlByZWRpY3QgYWN0aW9ucyBmb3IgbXVsdGlwbGUgc25hcHNob3RzIGF0IG9uY2UuCgogICAgUmVxdWVzdCBib2R5OiB7InNuYXBzaG90cyI6IFsuLi5dLCAiZ3Jl' +
'ZWR5IjogdHJ1ZX0KICAgICIiIgogICAgc3ZjID0gX2dldF9wb2xpY3lfc2VydmljZSgpCiAgICBpZiBzdmMgaXMgTm9uZSBvciBub3Qgc3ZjLl9sb2FkZWQ6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbihzdGF0dXNfY29kZT01MDMsIGRldGFpbD0iUG9saWN5IG1vZGVsIG5vdCBsb2FkZWQiKQoKICAgIGJvZHkgPSBhd2FpdCByZXF1ZXN0Lmpzb24oKQogICAgc25hcHNob3RzID0gYm9keS5nZXQoInNuYXBzaG90cyIsIFtdKQogICAgZ3JlZWR5ID0gYm9keS5nZXQoImdyZWVkeSIsIFRydWUpCgogICAgaWYgbm90IHNuYXBzaG90czoKICAgICAgICByZXR1cm4geyJyZXN1bHRzIjogW119CgogICAgcmVzdWx0cyA9IHN2Yy5wcmVkaWN0X2JhdGNoKHNuYXBzaG90cywgZ3JlZWR5PWdyZWVkeSkKICAgIHJldHVybiB7InJlc3VsdHMiOiByZXN1bHRzLCAiY291bnQiOiBsZW4ocmVzdWx0cyl9CgoKQGFwcC5nZXQoIi9hcGkvbWwvbW9kZWwiKQphc3luYyBkZWYgbWxfbW9kZWxfaW5mbygpOgogICAgIiIiR2V0IGluZm9ybWF0aW9uIGFib3V0IHRoZSBsb2FkZWQgcG9saWN5IG1vZGVsLiIiIgogICAgc3ZjID0gX2dldF9wb2xpY3lfc2VydmljZSgpCiAgICBpZiBzdmMgaXMgTm9uZToKICAgICAgICByZXR1cm4gewogICAgICAgICAgICAibG9hZGVkIjogRmFsc2UsCiAgICAgICAgICAgICJlcnJvciI6ICJQb2xpY3kgc2VydmljZSBub3QgaW5pdGlhbGl6ZWQiLAogICAgICAgICAgICAidG9yY2hfYXZhaWxhYmxlIjogRmFsc2UsCiAgICAgICAgfQogICAgcmV0dXJuIHN2Yy5nZXRfc3RhdHVzKCkKCgpAYXBwLnBvc3QoIi9hcGkvbWwvcmVsb2FkIikKYXN5bmMgZGVmIG1sX3JlbG9hZF9tb2RlbChjaGVja3BvaW50OiBzdHIgPSBOb25lKToKICAgICIiIkhvdC1yZWxvYWQgYSBwb2xpY3kgbW9kZWwgY2hlY2twb2ludC4iIiIKICAgIHN2YyA9IF9nZXRfcG9saWN5X3NlcnZpY2UoKQogICAgaWYgc3ZjIGlzIE5vbmU6CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbihzdGF0dXNfY29kZT01MDMsIGRldGFpbD0iUG9saWN5IHNlcnZpY2Ugbm90IGF2YWlsYWJsZSIpCgogICAgIyBJZiBzZXJ2aWNlIGhhc24ndCBsb2FkZWQgeWV0LCB0cnkgZnVsbCBsb2FkCiAgICBpZiBub3Qgc3ZjLl9sb2FkZWQ6CiAgICAgICAgb2sgPSBzdmMubG9hZCgpCiAgICBlbHNlOgogICAgICAgIG9rID0gc3ZjLnJlbG9hZChjaGVja3BvaW50KQoKICAgIHJldHVybiB7CiAgICAgICAgInN1Y2Nlc3MiOiBvaywKICAgICAgICAic3RhdHVzIjogc3ZjLmdldF9zdGF0dXMoKSwKICAgIH0KCgojIOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAojIE1MIFRyYWluaW5nIE1hbmFnZW1lbnQgRW5kcG9pbnRzCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCgojIFRyYWluaW5nIHN0YXRlIHRyYWNraW5nCl90cmFpbmluZ19zdGF0ZSA9IHsKICAgICJydW5uaW5nIjogRmFsc2UsCiAgICAicHJvZ3Jlc3MiOiAwLAogICAgInRvdGFsX2Vwb2NocyI6IDAsCiAgICAiY3VycmVudF9lcG9jaCI6IDAsCiAgICAicGhhc2UiOiAiaWRsZSIsICAjIGlkbGUgfCBidWlsZGluZyB8IHRyYWluaW5nIHwgZXZhbHVhdGluZyB8IGRvbmUgfCBlcnJvcgogICAgIm1lc3NhZ2UiOiAiIiwKICAgICJtZXRyaWNzIjogTm9uZSwgICMgbGF0ZXN0IGVwb2NoIG1ldHJpY3MKICAgICJyZXN1bHQiOiBOb25lLCAgICMgZmluYWwgdHJhaW5pbmcgcmVzdWx0CiAgICAiZXJyb3IiOiBOb25lLAogICAgInN0YXJ0ZWRfYXQiOiBOb25lLAp9CgoKZGVmIF9ydW5fdHJhaW5pbmdfcGlwZWxpbmUoCiAgICByZXN1bHRzX2Rpcjogc3RyLAogICAgZXBvY2hzOiBpbnQsCiAgICBscjogZmxvYXQsCiAgICBiYXRjaF9zaXplOiBpbnQsCiAgICBwYXRpZW5jZTogaW50LAogICAgcmVidWlsZF9kYXRhc2V0OiBib29sLAopOgogICAgIiIiUnVuIHRoZSBmdWxsIE1MIHRyYWluaW5n' +
'IHBpcGVsaW5lIGluIGEgYmFja2dyb3VuZCB0aHJlYWQuIiIiCiAgICBnbG9iYWwgX3RyYWluaW5nX3N0YXRlCiAgICB0cnk6CiAgICAgICAgaW1wb3J0IHN5cwogICAgICAgIHByb2plY3Rfcm9vdCA9IHN0cihQYXRoKF9fZmlsZV9fKS5wYXJlbnQpCiAgICAgICAgaWYgcHJvamVjdF9yb290IG5vdCBpbiBzeXMucGF0aDoKICAgICAgICAgICAgc3lzLnBhdGguaW5zZXJ0KDAsIHByb2plY3Rfcm9vdCkKCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJydW5uaW5nIl0gPSBUcnVlCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJzdGFydGVkX2F0Il0gPSBkYXRldGltZS5ub3coKS5pc29mb3JtYXQoKQogICAgICAgIF90cmFpbmluZ19zdGF0ZVsiZXJyb3IiXSA9IE5vbmUKICAgICAgICBfdHJhaW5pbmdfc3RhdGVbInJlc3VsdCJdID0gTm9uZQoKICAgICAgICBkYXRhX2RpciA9IG9zLnBhdGguam9pbihwcm9qZWN0X3Jvb3QsICJtbCIsICJtb2RlbHMiKQogICAgICAgIGNrcHRfZGlyID0gb3MucGF0aC5qb2luKHByb2plY3Rfcm9vdCwgIm1sIiwgIm1vZGVscyIsICJjaGVja3BvaW50cyIpCiAgICAgICAgb3MubWFrZWRpcnMoZGF0YV9kaXIsIGV4aXN0X29rPVRydWUpCiAgICAgICAgb3MubWFrZWRpcnMoY2twdF9kaXIsIGV4aXN0X29rPVRydWUpCgogICAgICAgICMgLS0tIFBoYXNlIDE6IEJ1aWxkIERhdGFzZXQgLS0tCiAgICAgICAgdHJhaW5fcGF0aCA9IG9zLnBhdGguam9pbihkYXRhX2RpciwgInRyYWluLm5weiIpCiAgICAgICAgaWYgcmVidWlsZF9kYXRhc2V0IG9yIG5vdCBvcy5wYXRoLmV4aXN0cyh0cmFpbl9wYXRoKToKICAgICAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJwaGFzZSJdID0gImJ1aWxkaW5nIgogICAgICAgICAgICBfdHJhaW5pbmdfc3RhdGVbIm1lc3NhZ2UiXSA9ICJMb2FkaW5nIGNhcmQgZW1iZWRkaW5ncyAmIGJ1aWxkaW5nIGRhdGFzZXQuLi4iCiAgICAgICAgICAgIHByaW50KCJbTUwgVHJhaW5dIEJ1aWxkaW5nIGRhdGFzZXQgKGxvYWRpbmcgZW1iZWRkaW5ncywgbWF5IGF1dG8tZG93bmxvYWQpLi4uIikKCiAgICAgICAgICAgIGZyb20gbWwuZGF0YS5kYXRhc2V0X2J1aWxkZXIgaW1wb3J0IGJ1aWxkX2RhdGFzZXQsIHNwbGl0X2RhdGFzZXQsIHNhdmVfZGF0YXNldAogICAgICAgICAgICBkYXRhc2V0ID0gYnVpbGRfZGF0YXNldChyZXN1bHRzX2Rpcj1yZXN1bHRzX2RpcikKICAgICAgICAgICAgaWYgbm90IGRhdGFzZXQ6CiAgICAgICAgICAgICAgICByYWlzZSBSdW50aW1lRXJyb3IoIk5vIHRyYWluaW5nIGRhdGEgcHJvZHVjZWQuIENoZWNrIHNlcnZlciBsb2cgZm9yIGRldGFpbHMuIikKICAgICAgICAgICAgdHJhaW5fZHMsIHZhbF9kcywgdGVzdF9kcyA9IHNwbGl0X2RhdGFzZXQoZGF0YXNldCkKICAgICAgICAgICAgc2F2ZV9kYXRhc2V0KHRyYWluX2RzLCBvcy5wYXRoLmpvaW4oZGF0YV9kaXIsICJ0cmFpbi5ucHoiKSkKICAgICAgICAgICAgc2F2ZV9kYXRhc2V0KHZhbF9kcywgb3MucGF0aC5qb2luKGRhdGFfZGlyLCAidmFsLm5weiIpKQogICAgICAgICAgICBzYXZlX2RhdGFzZXQodGVzdF9kcywgb3MucGF0aC5qb2luKGRhdGFfZGlyLCAidGVzdC5ucHoiKSkKICAgICAgICAgICAgdG90YWxfc2FtcGxlcyA9IGxlbihkYXRhc2V0WyJzdGF0ZXMiXSkKICAgICAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJtZXNzYWdlIl0gPSBmIkRhdGFzZXQgYnVpbHQ6IHt0b3RhbF9zYW1wbGVzfSBzYW1wbGVzIgogICAgICAgICAgICBwcmludChmIltNTCBUcmFpbl0gRGF0YXNldCBidWlsdDoge3RvdGFsX3NhbXBsZXN9IHNhbXBsZXMiKQoKICAgICAgICAjIC0tLSBQaGFzZSAyOiBUcmFpbiAtLS0KICAgICAgICBfdHJhaW5pbmdfc3RhdGVbInBoYXNlIl0gPSAidHJhaW5pbmciCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJ0b3RhbF9lcG9jaHMiXSA9IGVwb2NocwogICAgICAgIF90cmFpbmluZ19zdGF0ZVsiY3VycmVudF9lcG9jaCJdID0gMAogICAgICAgIF90cmFpbmluZ19zdGF0ZVsibWVzc2FnZSJdID0gZiJUcmFpbmluZyBwb2xpY3kgbmV0d29yayAoe2Vwb2Noc30gZXBvY2hzKS4uLiIKICAgICAgICBwcmludChmIltNTCBUcmFpbl0gU3RhcnRpbmcgdHJhaW5pbmc6IHtlcG9jaHN9IGVwb2NocywgbHI9e2xyfSwgYnM9e2JhdGNoX3NpemV9IikKCiAgICAgICAgaW1wb3J0IG51bXB5IGFzIG5wCiAgICAgICAgaW1wb3J0IHRvcmNoCiAgICAgICAgZnJvbSBtbC50cmFpbmluZy5wb2xpY3lfbmV0d29yayBpbXBvcnQgUG9saWN5TmV0' +
'd29yawogICAgICAgIGZyb20gbWwudHJhaW5pbmcudHJhaW5lciBpbXBvcnQgU3VwZXJ2aXNlZFRyYWluZXIKCiAgICAgICAgZGVmIGxvYWRfbnB6X3NwbGl0KHBhdGgpOgogICAgICAgICAgICBkYXRhID0gbnAubG9hZChwYXRoKQogICAgICAgICAgICByZXR1cm4gZGF0YVsic3RhdGVzIl0uYXN0eXBlKG5wLmZsb2F0MzIpLCBkYXRhWyJsYWJlbHMiXS5hc3R5cGUobnAuaW50NjQpCgogICAgICAgIHRyYWluX3N0YXRlcywgdHJhaW5fbGFiZWxzID0gbG9hZF9ucHpfc3BsaXQodHJhaW5fcGF0aCkKICAgICAgICB2YWxfcGF0aCA9IG9zLnBhdGguam9pbihkYXRhX2RpciwgInZhbC5ucHoiKQogICAgICAgIHZhbF9zdGF0ZXMsIHZhbF9sYWJlbHMgPSBsb2FkX25wel9zcGxpdCh2YWxfcGF0aCkKCiAgICAgICAgYWN0dWFsX2RpbSA9IHRyYWluX3N0YXRlcy5zaGFwZVsxXQogICAgICAgIG1vZGVsID0gUG9saWN5TmV0d29yayhpbnB1dF9kaW09YWN0dWFsX2RpbSkKCiAgICAgICAgZGV2aWNlID0gImNwdSIKICAgICAgICBpZiB0b3JjaC5jdWRhLmlzX2F2YWlsYWJsZSgpOgogICAgICAgICAgICBkZXZpY2UgPSAiY3VkYSIKICAgICAgICBlbGlmIGhhc2F0dHIodG9yY2guYmFja2VuZHMsICJtcHMiKSBhbmQgdG9yY2guYmFja2VuZHMubXBzLmlzX2F2YWlsYWJsZSgpOgogICAgICAgICAgICBkZXZpY2UgPSAibXBzIgoKICAgICAgICB0cmFpbmVyID0gU3VwZXJ2aXNlZFRyYWluZXIoCiAgICAgICAgICAgIG1vZGVsPW1vZGVsLAogICAgICAgICAgICBkZXZpY2U9ZGV2aWNlLAogICAgICAgICAgICBsZWFybmluZ19yYXRlPWxyLAogICAgICAgICAgICBiYXRjaF9zaXplPWJhdGNoX3NpemUsCiAgICAgICAgICAgIGVwb2Nocz1lcG9jaHMsCiAgICAgICAgICAgIHBhdGllbmNlPXBhdGllbmNlLAogICAgICAgICAgICBjaGVja3BvaW50X2Rpcj1ja3B0X2RpciwKICAgICAgICApCgogICAgICAgICMgSG9vayBpbnRvIHRyYWluZXIgdG8gcmVwb3J0IHByb2dyZXNzCiAgICAgICAgb3JpZ2luYWxfdHJhaW4gPSB0cmFpbmVyLnRyYWluCgogICAgICAgIGRlZiBwYXRjaGVkX3RyYWluKHRyYWluX3MsIHRyYWluX2wsIHZhbF9zLCB2YWxfbCk6CiAgICAgICAgICAgICMgV2UnbGwgbW9uaXRvciB0aGUgY2hlY2twb2ludCBkaXIgZm9yIHByb2dyZXNzCiAgICAgICAgICAgIHJldHVybiBvcmlnaW5hbF90cmFpbih0cmFpbl9zLCB0cmFpbl9sLCB2YWxfcywgdmFsX2wpCgogICAgICAgIHN1bW1hcnkgPSB0cmFpbmVyLnRyYWluKHRyYWluX3N0YXRlcywgdHJhaW5fbGFiZWxzLCB2YWxfc3RhdGVzLCB2YWxfbGFiZWxzKQoKICAgICAgICBfdHJhaW5pbmdfc3RhdGVbImN1cnJlbnRfZXBvY2giXSA9IHN1bW1hcnkuZ2V0KCJlcG9jaHNfdHJhaW5lZCIsIGVwb2NocykKICAgICAgICBfdHJhaW5pbmdfc3RhdGVbIm1ldHJpY3MiXSA9IHN1bW1hcnkKCiAgICAgICAgIyAtLS0gUGhhc2UgMzogRXZhbHVhdGUgLS0tCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJwaGFzZSJdID0gImV2YWx1YXRpbmciCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJtZXNzYWdlIl0gPSAiRXZhbHVhdGluZyBvbiB0ZXN0IHNldC4uLiIKCiAgICAgICAgdGVzdF9wYXRoID0gb3MucGF0aC5qb2luKGRhdGFfZGlyLCAidGVzdC5ucHoiKQogICAgICAgIGV2YWxfcmVzdWx0cyA9IE5vbmUKICAgICAgICBpZiBvcy5wYXRoLmV4aXN0cyh0ZXN0X3BhdGgpOgogICAgICAgICAgICBmcm9tIG1sLnRyYWluaW5nLnRyYWluZXIgaW1wb3J0IGxvYWRfY2hlY2twb2ludCwgZXZhbHVhdGVfbW9kZWwKICAgICAgICAgICAgdGVzdF9zdGF0ZXMsIHRlc3RfbGFiZWxzID0gbG9hZF9ucHpfc3BsaXQodGVzdF9wYXRoKQogICAgICAgICAgICBiZXN0X21vZGVsLCBfID0gbG9hZF9jaGVja3BvaW50KHN1bW1hcnlbImNoZWNrcG9pbnRfcGF0aCJdLCBkZXZpY2UpCiAgICAgICAgICAgIGV2YWxfcmVzdWx0cyA9IGV2YWx1YXRlX21vZGVsKGJlc3RfbW9kZWwsIHRlc3Rfc3RhdGVzLCB0ZXN0X2xhYmVscywgZGV2aWNlKQoKICAgICAgICAgICAgZXZhbF9wYXRoID0gb3MucGF0aC5qb2luKGNrcHRfZGlyLCAiZXZhbF9yZXN1bHRzLmpzb24iKQogICAgICAgICAgICB3aXRoIG9wZW4oZXZhbF9wYXRoLCAidyIpIGFzIGY6CiAgICAgICAgICAgICAgICBqc29uLmR1bXAoZXZhbF9yZXN1bHRzLCBmLCBpbmRlbnQ9MikKCiAgICAgICAgIyAtLS0gRG9uZSAtLS0KICAgICAgICBfdHJhaW5pbmdfc3RhdGVbInBoYXNlIl0gPSAiZG9uZSIKICAg' +
'ICAgICBfdHJhaW5pbmdfc3RhdGVbInJ1bm5pbmciXSA9IEZhbHNlCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJyZXN1bHQiXSA9IHsKICAgICAgICAgICAgInRyYWluaW5nIjogc3VtbWFyeSwKICAgICAgICAgICAgImV2YWx1YXRpb24iOiBldmFsX3Jlc3VsdHMsCiAgICAgICAgICAgICJjaGVja3BvaW50Ijogc3VtbWFyeS5nZXQoImNoZWNrcG9pbnRfcGF0aCIsICIiKSwKICAgICAgICAgICAgImRldmljZSI6IGRldmljZSwKICAgICAgICB9CiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJtZXNzYWdlIl0gPSBmIlRyYWluaW5nIGNvbXBsZXRlISBCZXN0IHZhbCBhY2M6IHtzdW1tYXJ5LmdldCgnYmVzdF92YWxfYWNjdXJhY3knLCAwKTouMSV9IgogICAgICAgIHByaW50KGYiW01MIFRyYWluXSBDb21wbGV0ZToge3N1bW1hcnkuZ2V0KCdiZXN0X3ZhbF9hY2N1cmFjeScsIDApOi4xJX0gdmFsIGFjY3VyYWN5IikKCiAgICAgICAgIyBBdXRvLXJlbG9hZCBwb2xpY3kgc2VydmVyIHdpdGggbmV3IGNoZWNrcG9pbnQKICAgICAgICBnbG9iYWwgX3BvbGljeV9zZXJ2aWNlLCBfcG9saWN5X3NlcnZpY2VfaW5pdF9hdHRlbXB0ZWQKICAgICAgICBpZiBfcG9saWN5X3NlcnZpY2UgYW5kIF9wb2xpY3lfc2VydmljZS5fbG9hZGVkOgogICAgICAgICAgICBfcG9saWN5X3NlcnZpY2UucmVsb2FkKHN1bW1hcnkuZ2V0KCJjaGVja3BvaW50X3BhdGgiKSkKICAgICAgICAgICAgcHJpbnQoIltNTCBUcmFpbl0gUG9saWN5IHNlcnZlciByZWxvYWRlZCB3aXRoIG5ldyBjaGVja3BvaW50IikKICAgICAgICBlbGlmIG5vdCBfcG9saWN5X3NlcnZpY2VfaW5pdF9hdHRlbXB0ZWQ6CiAgICAgICAgICAgIF9wb2xpY3lfc2VydmljZV9pbml0X2F0dGVtcHRlZCA9IEZhbHNlICAjIEFsbG93IHJlLWluaXQgd2l0aCBuZXcgbW9kZWwKCiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgaW1wb3J0IHRyYWNlYmFjawogICAgICAgIF90cmFpbmluZ19zdGF0ZVsicGhhc2UiXSA9ICJlcnJvciIKICAgICAgICBfdHJhaW5pbmdfc3RhdGVbInJ1bm5pbmciXSA9IEZhbHNlCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJlcnJvciJdID0gc3RyKGUpCiAgICAgICAgX3RyYWluaW5nX3N0YXRlWyJtZXNzYWdlIl0gPSBmIlRyYWluaW5nIGZhaWxlZDoge2V9IgogICAgICAgIHByaW50KGYiW01MIFRyYWluXSBFUlJPUjoge2V9IikKICAgICAgICB0cmFjZWJhY2sucHJpbnRfZXhjKCkKCgpAYXBwLnBvc3QoIi9hcGkvbWwvdHJhaW4iKQphc3luYyBkZWYgbWxfc3RhcnRfdHJhaW5pbmcocmVxdWVzdDogRmFzdEFQSVJlcXVlc3QpOgogICAgIiIiVHJpZ2dlciBNTCB0cmFpbmluZyBwaXBlbGluZSBmcm9tIHRoZSB3ZWIgVUkuCgogICAgUmVxdWVzdCBib2R5IChhbGwgb3B0aW9uYWwpOgogICAgICAgIHsKICAgICAgICAgICAgImVwb2NocyI6IDUwLAogICAgICAgICAgICAibHIiOiAwLjAwMSwKICAgICAgICAgICAgImJhdGNoU2l6ZSI6IDI1NiwKICAgICAgICAgICAgInBhdGllbmNlIjogMTAsCiAgICAgICAgICAgICJyZWJ1aWxkRGF0YXNldCI6IHRydWUKICAgICAgICB9CiAgICAiIiIKICAgIGlmIF90cmFpbmluZ19zdGF0ZVsicnVubmluZyJdOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNDA5LCAiVHJhaW5pbmcgYWxyZWFkeSBpbiBwcm9ncmVzcyIpCgogICAgYm9keSA9IGF3YWl0IHJlcXVlc3QuanNvbigpIGlmIGF3YWl0IHJlcXVlc3QuYm9keSgpIGVsc2Uge30KICAgIGVwb2NocyA9IGJvZHkuZ2V0KCJlcG9jaHMiLCA1MCkKICAgIGxyID0gYm9keS5nZXQoImxyIiwgMC4wMDEpCiAgICBiYXRjaF9zaXplID0gYm9keS5nZXQoImJhdGNoU2l6ZSIsIDI1NikKICAgIHBhdGllbmNlID0gYm9keS5nZXQoInBhdGllbmNlIiwgMTApCiAgICByZWJ1aWxkID0gYm9keS5nZXQoInJlYnVpbGREYXRhc2V0IiwgVHJ1ZSkKCiAgICByZXN1bHRzX2RpciA9IG9zLnBhdGguam9pbihzdHIoUGF0aChfX2ZpbGVfXykucGFyZW50KSwgInJlc3VsdHMiKQoKICAgICMgUmVzZXQgc3RhdGUKICAgIF90cmFpbmluZ19zdGF0ZS51cGRhdGUoewogICAgICAgICJydW5uaW5nIjogVHJ1ZSwKICAgICAgICAicHJvZ3Jlc3MiOiAwLAogICAgICAgICJ0b3RhbF9lcG9jaHMiOiBlcG9jaHMsCiAgICAgICAgImN1cnJlbnRfZXBvY2giOiAwLAogICAgICAgICJwaGFzZSI6ICJzdGFydGluZyIsCiAgICAgICAgIm1lc3NhZ2UiOiAiSW5pdGlhbGl6aW5nIHRyYWluaW5nIHBpcGVsaW5lLi4uIiwKICAgICAgICAi' +
'bWV0cmljcyI6IE5vbmUsCiAgICAgICAgInJlc3VsdCI6IE5vbmUsCiAgICAgICAgImVycm9yIjogTm9uZSwKICAgIH0pCgogICAgIyBSdW4gaW4gYmFja2dyb3VuZCB0aHJlYWQKICAgIHRocmVhZCA9IHRocmVhZGluZy5UaHJlYWQoCiAgICAgICAgdGFyZ2V0PV9ydW5fdHJhaW5pbmdfcGlwZWxpbmUsCiAgICAgICAgYXJncz0ocmVzdWx0c19kaXIsIGVwb2NocywgbHIsIGJhdGNoX3NpemUsIHBhdGllbmNlLCByZWJ1aWxkKSwKICAgICAgICBkYWVtb249VHJ1ZSwKICAgICkKICAgIHRocmVhZC5zdGFydCgpCgogICAgcmV0dXJuIHsKICAgICAgICAic3RhdHVzIjogInN0YXJ0ZWQiLAogICAgICAgICJjb25maWciOiB7CiAgICAgICAgICAgICJlcG9jaHMiOiBlcG9jaHMsCiAgICAgICAgICAgICJsciI6IGxyLAogICAgICAgICAgICAiYmF0Y2hTaXplIjogYmF0Y2hfc2l6ZSwKICAgICAgICAgICAgInBhdGllbmNlIjogcGF0aWVuY2UsCiAgICAgICAgICAgICJyZWJ1aWxkRGF0YXNldCI6IHJlYnVpbGQsCiAgICAgICAgfSwKICAgIH0KCgpAYXBwLmdldCgiL2FwaS9tbC90cmFpbi9zdGF0dXMiKQphc3luYyBkZWYgbWxfdHJhaW5pbmdfc3RhdHVzKCk6CiAgICAiIiJHZXQgY3VycmVudCB0cmFpbmluZyBwaXBlbGluZSBzdGF0dXMuIiIiCiAgICByZXR1cm4gZGljdChfdHJhaW5pbmdfc3RhdGUpCgoKQGFwcC5nZXQoIi9hcGkvbWwvZGF0YS9zdGF0dXMiKQphc3luYyBkZWYgbWxfZGF0YV9zdGF0dXMoKToKICAgICIiIkdldCBzdGF0dXMgb2YgYXZhaWxhYmxlIHRyYWluaW5nIGRhdGEgYW5kIG1vZGVsIGNoZWNrcG9pbnRzLiIiIgogICAgcHJvamVjdF9yb290ID0gUGF0aChfX2ZpbGVfXykucGFyZW50CiAgICByZXN1bHRzX2RpciA9IHByb2plY3Rfcm9vdCAvICJyZXN1bHRzIgogICAgZGF0YV9kaXIgPSBwcm9qZWN0X3Jvb3QgLyAibWwiIC8gIm1vZGVscyIKICAgIGNrcHRfZGlyID0gZGF0YV9kaXIgLyAiY2hlY2twb2ludHMiCgogICAgIyBEZWNpc2lvbiBsb2cgZmlsZXMKICAgIGRlY2lzaW9uX2ZpbGVzID0gW10KICAgIGlmIHJlc3VsdHNfZGlyLmV4aXN0cygpOgogICAgICAgIGZvciBmIGluIHNvcnRlZChyZXN1bHRzX2Rpci5nbG9iKCJtbC1kZWNpc2lvbnMtKi5qc29ubCIpKToKICAgICAgICAgICAgbGluZV9jb3VudCA9IDAKICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgd2l0aCBvcGVuKGYsICJyIikgYXMgZmg6CiAgICAgICAgICAgICAgICAgICAgbGluZV9jb3VudCA9IHN1bSgxIGZvciBfIGluIGZoKQogICAgICAgICAgICBleGNlcHQgRXhjZXB0aW9uOgogICAgICAgICAgICAgICAgcGFzcwogICAgICAgICAgICBkZWNpc2lvbl9maWxlcy5hcHBlbmQoewogICAgICAgICAgICAgICAgIm5hbWUiOiBmLm5hbWUsCiAgICAgICAgICAgICAgICAic2l6ZSI6IGYuc3RhdCgpLnN0X3NpemUsCiAgICAgICAgICAgICAgICAiZGVjaXNpb25zIjogbGluZV9jb3VudCwKICAgICAgICAgICAgfSkKCiAgICAjIERhdGFzZXQgZmlsZXMKICAgIGRhdGFzZXRzID0ge30KICAgIGZvciBzcGxpdCBpbiBbInRyYWluIiwgInZhbCIsICJ0ZXN0Il06CiAgICAgICAgcGF0aCA9IGRhdGFfZGlyIC8gZiJ7c3BsaXR9Lm5weiIKICAgICAgICBpZiBwYXRoLmV4aXN0cygpOgogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICBpbXBvcnQgbnVtcHkgYXMgbnAKICAgICAgICAgICAgICAgIGRhdGEgPSBucC5sb2FkKHN0cihwYXRoKSkKICAgICAgICAgICAgICAgIGRhdGFzZXRzW3NwbGl0XSA9IHsKICAgICAgICAgICAgICAgICAgICAic2FtcGxlcyI6IGludChkYXRhWyJzdGF0ZXMiXS5zaGFwZVswXSksCiAgICAgICAgICAgICAgICAgICAgImZlYXR1cmVzIjogaW50KGRhdGFbInN0YXRlcyJdLnNoYXBlWzFdKSwKICAgICAgICAgICAgICAgICAgICAic2l6ZSI6IHBhdGguc3RhdCgpLnN0X3NpemUsCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIGV4Y2VwdCBFeGNlcHRpb246CiAgICAgICAgICAgICAgICBkYXRhc2V0c1tzcGxpdF0gPSB7InNpemUiOiBwYXRoLnN0YXQoKS5zdF9zaXplfQoKICAgICMgQ2hlY2twb2ludHMKICAgIGNoZWNrcG9pbnRzID0gW10KICAgIGlmIGNrcHRfZGlyLmV4aXN0cygpOgogICAgICAgIGZvciBmIGluIHNvcnRlZChja3B0X2Rpci5nbG9iKCIqLnB0IikpOgogICAgICAgICAgICBjaGVja3BvaW50cy5hcHBlbmQoewogICAgICAgICAgICAgICAgIm5hbWUiOiBmLm5hbWUsCiAgICAgICAgICAgICAgICAic2l6ZSI6IGYu' +
'c3RhdCgpLnN0X3NpemUsCiAgICAgICAgICAgICAgICAibW9kaWZpZWQiOiBkYXRldGltZS5mcm9tdGltZXN0YW1wKGYuc3RhdCgpLnN0X210aW1lKS5pc29mb3JtYXQoKSwKICAgICAgICAgICAgfSkKCiAgICAjIEV2YWwgcmVzdWx0cwogICAgZXZhbF9wYXRoID0gY2twdF9kaXIgLyAiZXZhbF9yZXN1bHRzLmpzb24iCiAgICBldmFsX3Jlc3VsdHMgPSBOb25lCiAgICBpZiBldmFsX3BhdGguZXhpc3RzKCk6CiAgICAgICAgdHJ5OgogICAgICAgICAgICB3aXRoIG9wZW4oZXZhbF9wYXRoLCAiciIpIGFzIGY6CiAgICAgICAgICAgICAgICBldmFsX3Jlc3VsdHMgPSBqc29uLmxvYWQoZikKICAgICAgICBleGNlcHQgRXhjZXB0aW9uOgogICAgICAgICAgICBwYXNzCgogICAgcmV0dXJuIHsKICAgICAgICAiZGVjaXNpb25GaWxlcyI6IGRlY2lzaW9uX2ZpbGVzLAogICAgICAgICJ0b3RhbERlY2lzaW9ucyI6IHN1bShkWyJkZWNpc2lvbnMiXSBmb3IgZCBpbiBkZWNpc2lvbl9maWxlcyksCiAgICAgICAgImRhdGFzZXRzIjogZGF0YXNldHMsCiAgICAgICAgImNoZWNrcG9pbnRzIjogY2hlY2twb2ludHMsCiAgICAgICAgImV2YWxSZXN1bHRzIjogZXZhbF9yZXN1bHRzLAogICAgICAgICJwb2xpY3lMb2FkZWQiOiBfcG9saWN5X3NlcnZpY2UgaXMgbm90IE5vbmUgYW5kIF9wb2xpY3lfc2VydmljZS5fbG9hZGVkIGlmIF9wb2xpY3lfc2VydmljZSBlbHNlIEZhbHNlLAogICAgfQoKCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCiMgUFBPIFRyYWluaW5nICsgVG91cm5hbWVudCBFbmRwb2ludHMKIyDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZAKCl9wcG9fc3RhdGUgPSB7CiAgICAicnVubmluZyI6IEZhbHNlLAogICAgIml0ZXJhdGlvbiI6IDAsCiAgICAidG90YWxfaXRlcmF0aW9ucyI6IDAsCiAgICAicGhhc2UiOiAiaWRsZSIsCiAgICAibWVzc2FnZSI6ICIiLAogICAgIm1ldHJpY3MiOiBOb25lLAogICAgInJlc3VsdCI6IE5vbmUsCiAgICAiZXJyb3IiOiBOb25lLAp9CgpfdG91cm5hbWVudF9zdGF0ZSA9IHsKICAgICJydW5uaW5nIjogRmFsc2UsCiAgICAicGhhc2UiOiAiaWRsZSIsCiAgICAibWVzc2FnZSI6ICIiLAogICAgInJlc3VsdCI6IE5vbmUsCiAgICAiZXJyb3IiOiBOb25lLAp9CgoKZGVmIF9ydW5fcHBvX3BpcGVsaW5lKAogICAgaXRlcmF0aW9uczogaW50LCBlcGlzb2Rlc19wZXJfaXRlcjogaW50LCBwcG9fZXBvY2hzOiBpbnQsIGJhdGNoX3NpemU6IGludCwKICAgIGxyOiBmbG9hdCwgY2xpcF9lcHNpbG9uOiBmbG9hdCwgZW50cm9weV9jb2VmZjogZmxvYXQsCiAgICBvcHBvbmVudDogc3RyLCBwbGF5c3R5bGU6IHN0ciwgbG9hZF9zdXBlcnZpc2VkOiBzdHIsCik6CiAgICAiIiJSdW4gUFBPIHRyYWluaW5nIGluIGEgYmFja2dyb3VuZCB0aHJlYWQuIiIiCiAgICBnbG9iYWwgX3Bwb19zdGF0ZQogICAgdHJ5OgogICAgICAgIGltcG9ydCBzeXMKICAgICAgICBwcm9qZWN0X3Jvb3QgPSBzdHIoUGF0aChfX2ZpbGVfXykucGFyZW50KQogICAgICAgIGlmIHByb2plY3Rfcm9vdCBub3QgaW4gc3lzLnBhdGg6CiAgICAgICAgICAgIHN5cy5wYXRoLmluc2VydCgwLCBwcm9qZWN0X3Jvb3QpCgogICAgICAgIF9wcG9fc3RhdGVbInJ1bm5pbmciXSA9IFRydWUKICAgICAgICBfcHBvX3N0YXRlWyJlcnJvciJdID0gTm9uZQogICAgICAgIF9wcG9fc3RhdGVbInJlc3VsdCJdID0gTm9uZQoKICAgICAgICBmcm9tIG1sLnRyYWluaW5nLnBwb190cmFpbmVyIGltcG9ydCBQUE9UcmFpbmVyLCBQUE9Db25maWcKCiAgICAgICAgY2twdF9kaXIgPSBvcy5wYXRoLmpvaW4ocHJvamVjdF9yb290LCAibWwiLCAibW9kZWxzIiwgImNoZWNrcG9pbnRzIikKICAgICAgICBjb25maWcgPSBQUE9Db25maWcoCiAgICAgICAgICAgIGl0ZXJhdGlvbnM9aXRlcmF0aW9ucywKICAgICAgICAgICAgZXBpc29kZXNf' +
'cGVyX2l0ZXI9ZXBpc29kZXNfcGVyX2l0ZXIsCiAgICAgICAgICAgIHBwb19lcG9jaHM9cHBvX2Vwb2NocywKICAgICAgICAgICAgYmF0Y2hfc2l6ZT1iYXRjaF9zaXplLAogICAgICAgICAgICBsZWFybmluZ19yYXRlPWxyLAogICAgICAgICAgICBjbGlwX2Vwc2lsb249Y2xpcF9lcHNpbG9uLAogICAgICAgICAgICBlbnRyb3B5X2NvZWZmPWVudHJvcHlfY29lZmYsCiAgICAgICAgICAgIG9wcG9uZW50PW9wcG9uZW50LAogICAgICAgICAgICBwbGF5c3R5bGU9cGxheXN0eWxlLAogICAgICAgICAgICBjaGVja3BvaW50X2Rpcj1ja3B0X2RpciwKICAgICAgICAgICAgbG9hZF9zdXBlcnZpc2VkPWxvYWRfc3VwZXJ2aXNlZCBpZiBsb2FkX3N1cGVydmlzZWQgZWxzZSBOb25lLAogICAgICAgICkKCiAgICAgICAgdHJhaW5lciA9IFBQT1RyYWluZXIoY29uZmlnKQoKICAgICAgICBkZWYgcHJvZ3Jlc3NfY2IoaXRlcmF0aW9uLCBtZXRyaWNzKToKICAgICAgICAgICAgX3Bwb19zdGF0ZVsiaXRlcmF0aW9uIl0gPSBpdGVyYXRpb24KICAgICAgICAgICAgX3Bwb19zdGF0ZVsicGhhc2UiXSA9ICJ0cmFpbmluZyIKICAgICAgICAgICAgX3Bwb19zdGF0ZVsibWVzc2FnZSJdID0gZiJJdGVyYXRpb24ge2l0ZXJhdGlvbn0ve2l0ZXJhdGlvbnN9IHwgV1I6IHttZXRyaWNzLmdldCgnd2luX3JhdGUnLCAwKTouMCV9IgogICAgICAgICAgICBfcHBvX3N0YXRlWyJtZXRyaWNzIl0gPSBtZXRyaWNzCgogICAgICAgIHN1bW1hcnkgPSB0cmFpbmVyLnRyYWluKHByb2dyZXNzX2NhbGxiYWNrPXByb2dyZXNzX2NiKQoKICAgICAgICBfcHBvX3N0YXRlWyJwaGFzZSJdID0gImRvbmUiCiAgICAgICAgX3Bwb19zdGF0ZVsicnVubmluZyJdID0gRmFsc2UKICAgICAgICBfcHBvX3N0YXRlWyJyZXN1bHQiXSA9IHN1bW1hcnkKICAgICAgICBfcHBvX3N0YXRlWyJtZXNzYWdlIl0gPSBmIlBQTyBjb21wbGV0ZSEgQmVzdCB3aW4gcmF0ZToge3N1bW1hcnkuZ2V0KCdiZXN0X3dpbl9yYXRlJywgMCk6LjAlfSIKCiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgaW1wb3J0IHRyYWNlYmFjawogICAgICAgIF9wcG9fc3RhdGVbInBoYXNlIl0gPSAiZXJyb3IiCiAgICAgICAgX3Bwb19zdGF0ZVsicnVubmluZyJdID0gRmFsc2UKICAgICAgICBfcHBvX3N0YXRlWyJlcnJvciJdID0gc3RyKGUpCiAgICAgICAgX3Bwb19zdGF0ZVsibWVzc2FnZSJdID0gZiJQUE8gZmFpbGVkOiB7ZX0iCiAgICAgICAgdHJhY2ViYWNrLnByaW50X2V4YygpCgoKQGFwcC5wb3N0KCIvYXBpL21sL3RyYWluL3BwbyIpCmFzeW5jIGRlZiBtbF9zdGFydF9wcG8ocmVxdWVzdDogRmFzdEFQSVJlcXVlc3QpOgogICAgIiIiU3RhcnQgUFBPIHRyYWluaW5nIHBpcGVsaW5lLiIiIgogICAgaWYgX3Bwb19zdGF0ZVsicnVubmluZyJdOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNDA5LCAiUFBPIHRyYWluaW5nIGFscmVhZHkgaW4gcHJvZ3Jlc3MiKQogICAgaWYgX3RyYWluaW5nX3N0YXRlWyJydW5uaW5nIl06CiAgICAgICAgcmFpc2UgSFRUUEV4Y2VwdGlvbig0MDksICJTdXBlcnZpc2VkIHRyYWluaW5nIGluIHByb2dyZXNzIOKAlCB3YWl0IGZvciBpdCB0byBmaW5pc2giKQoKICAgIGJvZHkgPSBhd2FpdCByZXF1ZXN0Lmpzb24oKSBpZiBhd2FpdCByZXF1ZXN0LmJvZHkoKSBlbHNlIHt9CiAgICBpdGVyYXRpb25zID0gYm9keS5nZXQoIml0ZXJhdGlvbnMiLCAxMDApCiAgICBlcGlzb2RlcyA9IGJvZHkuZ2V0KCJlcGlzb2Rlc1Blckl0ZXIiLCA2NCkKICAgIHBwb19lcG9jaHMgPSBib2R5LmdldCgicHBvRXBvY2hzIiwgNCkKICAgIGJhdGNoX3NpemUgPSBib2R5LmdldCgiYmF0Y2hTaXplIiwgMjU2KQogICAgbHIgPSBib2R5LmdldCgibHIiLCAzZS00KQogICAgY2xpcF9lcHMgPSBib2R5LmdldCgiY2xpcEVwc2lsb24iLCAwLjIpCiAgICBlbnRyb3B5ID0gYm9keS5nZXQoImVudHJvcHlDb2VmZiIsIDAuMDEpCiAgICBvcHBvbmVudCA9IGJvZHkuZ2V0KCJvcHBvbmVudCIsICJoZXVyaXN0aWMiKQogICAgcGxheXN0eWxlID0gYm9keS5nZXQoInBsYXlzdHlsZSIsICJtaWRyYW5nZSIpCiAgICBsb2FkX3N1cCA9IGJvZHkuZ2V0KCJsb2FkU3VwZXJ2aXNlZCIsICIiKQoKICAgIF9wcG9fc3RhdGUudXBkYXRlKHsKICAgICAgICAicnVubmluZyI6IFRydWUsICJpdGVyYXRpb24iOiAwLCAidG90YWxfaXRlcmF0aW9ucyI6IGl0ZXJhdGlvbnMsCiAgICAgICAgInBoYXNlIjogInN0YXJ0aW5nIiwgIm1lc3NhZ2UiOiAi' +
'SW5pdGlhbGl6aW5nIFBQTy4uLiIsCiAgICAgICAgIm1ldHJpY3MiOiBOb25lLCAicmVzdWx0IjogTm9uZSwgImVycm9yIjogTm9uZSwKICAgIH0pCgogICAgdGhyZWFkID0gdGhyZWFkaW5nLlRocmVhZCgKICAgICAgICB0YXJnZXQ9X3J1bl9wcG9fcGlwZWxpbmUsCiAgICAgICAgYXJncz0oaXRlcmF0aW9ucywgZXBpc29kZXMsIHBwb19lcG9jaHMsIGJhdGNoX3NpemUsIGxyLCBjbGlwX2VwcywgZW50cm9weSwgb3Bwb25lbnQsIHBsYXlzdHlsZSwgbG9hZF9zdXApLAogICAgICAgIGRhZW1vbj1UcnVlLAogICAgKQogICAgdGhyZWFkLnN0YXJ0KCkKCiAgICByZXR1cm4geyJzdGF0dXMiOiAic3RhcnRlZCIsICJpdGVyYXRpb25zIjogaXRlcmF0aW9uc30KCgpAYXBwLmdldCgiL2FwaS9tbC90cmFpbi9wcG8vc3RhdHVzIikKYXN5bmMgZGVmIG1sX3Bwb19zdGF0dXMoKToKICAgICIiIkdldCBQUE8gdHJhaW5pbmcgc3RhdHVzLiIiIgogICAgcmV0dXJuIGRpY3QoX3Bwb19zdGF0ZSkKCgpkZWYgX3J1bl90b3VybmFtZW50X3BpcGVsaW5lKGVwaXNvZGVzOiBpbnQsIHBsYXlzdHlsZTogc3RyKToKICAgICIiIlJ1biB0b3VybmFtZW50IGluIGEgYmFja2dyb3VuZCB0aHJlYWQuIiIiCiAgICBnbG9iYWwgX3RvdXJuYW1lbnRfc3RhdGUKICAgIHRyeToKICAgICAgICBpbXBvcnQgc3lzCiAgICAgICAgcHJvamVjdF9yb290ID0gc3RyKFBhdGgoX19maWxlX18pLnBhcmVudCkKICAgICAgICBpZiBwcm9qZWN0X3Jvb3Qgbm90IGluIHN5cy5wYXRoOgogICAgICAgICAgICBzeXMucGF0aC5pbnNlcnQoMCwgcHJvamVjdF9yb290KQoKICAgICAgICBfdG91cm5hbWVudF9zdGF0ZVsicnVubmluZyJdID0gVHJ1ZQogICAgICAgIF90b3VybmFtZW50X3N0YXRlWyJlcnJvciJdID0gTm9uZQogICAgICAgIF90b3VybmFtZW50X3N0YXRlWyJyZXN1bHQiXSA9IE5vbmUKICAgICAgICBfdG91cm5hbWVudF9zdGF0ZVsicGhhc2UiXSA9ICJydW5uaW5nIgogICAgICAgIF90b3VybmFtZW50X3N0YXRlWyJtZXNzYWdlIl0gPSAiUnVubmluZyB0b3VybmFtZW50Li4uIgoKICAgICAgICBmcm9tIG1sLmV2YWwudG91cm5hbWVudCBpbXBvcnQgKAogICAgICAgICAgICBydW5fdG91cm5hbWVudCwgSGV1cmlzdGljUG9saWN5LCBSYW5kb21Qb2xpY3ksIExlYXJuZWRQb2xpY3ksCiAgICAgICAgKQoKICAgICAgICBja3B0X2RpciA9IG9zLnBhdGguam9pbihwcm9qZWN0X3Jvb3QsICJtbCIsICJtb2RlbHMiLCAiY2hlY2twb2ludHMiKQogICAgICAgIHBvbGljaWVzID0gewogICAgICAgICAgICAiaGV1cmlzdGljIjogSGV1cmlzdGljUG9saWN5KCksCiAgICAgICAgICAgICJyYW5kb20iOiBSYW5kb21Qb2xpY3koKSwKICAgICAgICB9CgogICAgICAgIHN1cF9wYXRoID0gb3MucGF0aC5qb2luKGNrcHRfZGlyLCAiYmVzdF9wb2xpY3kucHQiKQogICAgICAgIGlmIG9zLnBhdGguZXhpc3RzKHN1cF9wYXRoKToKICAgICAgICAgICAgcG9saWNpZXNbInN1cGVydmlzZWQiXSA9IExlYXJuZWRQb2xpY3koc3VwX3BhdGgpCgogICAgICAgIHBwb19wYXRoID0gb3MucGF0aC5qb2luKGNrcHRfZGlyLCAiYmVzdF9wcG8ucHQiKQogICAgICAgIGlmIG9zLnBhdGguZXhpc3RzKHBwb19wYXRoKToKICAgICAgICAgICAgcG9saWNpZXNbInBwbyJdID0gTGVhcm5lZFBvbGljeShwcG9fcGF0aCkKCiAgICAgICAgcmVzdWx0ID0gcnVuX3RvdXJuYW1lbnQoCiAgICAgICAgICAgIHBvbGljaWVzPXBvbGljaWVzLAogICAgICAgICAgICBlcGlzb2Rlc19wZXJfbWF0Y2h1cD1lcGlzb2RlcywKICAgICAgICAgICAgcGxheXN0eWxlPXBsYXlzdHlsZSwKICAgICAgICApCgogICAgICAgICMgU2F2ZSByZXN1bHRzCiAgICAgICAgb3V0cHV0X3BhdGggPSBvcy5wYXRoLmpvaW4oY2twdF9kaXIsICJ0b3VybmFtZW50X3Jlc3VsdHMuanNvbiIpCiAgICAgICAgd2l0aCBvcGVuKG91dHB1dF9wYXRoLCAidyIpIGFzIGY6CiAgICAgICAgICAgIGpzb24uZHVtcChyZXN1bHQudG9fZGljdCgpLCBmLCBpbmRlbnQ9MikKCiAgICAgICAgX3RvdXJuYW1lbnRfc3RhdGVbInBoYXNlIl0gPSAiZG9uZSIKICAgICAgICBfdG91cm5hbWVudF9zdGF0ZVsicnVubmluZyJdID0gRmFsc2UKICAgICAgICBfdG91cm5hbWVudF9zdGF0ZVsicmVzdWx0Il0gPSByZXN1bHQudG9fZGljdCgpCiAgICAgICAgX3RvdXJuYW1lbnRfc3RhdGVbIm1lc3NhZ2UiXSA9IGYiVG91cm5hbWVudCBjb21wbGV0ZSEge3Jlc3VsdC50b3RhbF9tYXRjaGVzfSBtYXRjaGVz' +
'IgoKICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICBpbXBvcnQgdHJhY2ViYWNrCiAgICAgICAgX3RvdXJuYW1lbnRfc3RhdGVbInBoYXNlIl0gPSAiZXJyb3IiCiAgICAgICAgX3RvdXJuYW1lbnRfc3RhdGVbInJ1bm5pbmciXSA9IEZhbHNlCiAgICAgICAgX3RvdXJuYW1lbnRfc3RhdGVbImVycm9yIl0gPSBzdHIoZSkKICAgICAgICBfdG91cm5hbWVudF9zdGF0ZVsibWVzc2FnZSJdID0gZiJUb3VybmFtZW50IGZhaWxlZDoge2V9IgogICAgICAgIHRyYWNlYmFjay5wcmludF9leGMoKQoKCkBhcHAucG9zdCgiL2FwaS9tbC90b3VybmFtZW50IikKYXN5bmMgZGVmIG1sX3N0YXJ0X3RvdXJuYW1lbnQocmVxdWVzdDogRmFzdEFQSVJlcXVlc3QpOgogICAgIiIiU3RhcnQgYSB0b3VybmFtZW50IGV2YWx1YXRpb24uIiIiCiAgICBpZiBfdG91cm5hbWVudF9zdGF0ZVsicnVubmluZyJdOgogICAgICAgIHJhaXNlIEhUVFBFeGNlcHRpb24oNDA5LCAiVG91cm5hbWVudCBhbHJlYWR5IGluIHByb2dyZXNzIikKCiAgICBib2R5ID0gYXdhaXQgcmVxdWVzdC5qc29uKCkgaWYgYXdhaXQgcmVxdWVzdC5ib2R5KCkgZWxzZSB7fQogICAgZXBpc29kZXMgPSBib2R5LmdldCgiZXBpc29kZXMiLCA1MCkKICAgIHBsYXlzdHlsZSA9IGJvZHkuZ2V0KCJwbGF5c3R5bGUiLCAibWlkcmFuZ2UiKQoKICAgIF90b3VybmFtZW50X3N0YXRlLnVwZGF0ZSh7CiAgICAgICAgInJ1bm5pbmciOiBUcnVlLCAicGhhc2UiOiAic3RhcnRpbmciLAogICAgICAgICJtZXNzYWdlIjogIkluaXRpYWxpemluZyB0b3VybmFtZW50Li4uIiwKICAgICAgICAicmVzdWx0IjogTm9uZSwgImVycm9yIjogTm9uZSwKICAgIH0pCgogICAgdGhyZWFkID0gdGhyZWFkaW5nLlRocmVhZCgKICAgICAgICB0YXJnZXQ9X3J1bl90b3VybmFtZW50X3BpcGVsaW5lLAogICAgICAgIGFyZ3M9KGVwaXNvZGVzLCBwbGF5c3R5bGUpLAogICAgICAgIGRhZW1vbj1UcnVlLAogICAgKQogICAgdGhyZWFkLnN0YXJ0KCkKCiAgICByZXR1cm4geyJzdGF0dXMiOiAic3RhcnRlZCIsICJlcGlzb2RlcyI6IGVwaXNvZGVzfQoKCkBhcHAuZ2V0KCIvYXBpL21sL3RvdXJuYW1lbnQvc3RhdHVzIikKYXN5bmMgZGVmIG1sX3RvdXJuYW1lbnRfc3RhdHVzKCk6CiAgICAiIiJHZXQgdG91cm5hbWVudCBzdGF0dXMuIiIiCiAgICByZXR1cm4gZGljdChfdG91cm5hbWVudF9zdGF0ZSkKCgpAYXBwLmdldCgiL2FwaS9tbC90b3VybmFtZW50L3Jlc3VsdHMiKQphc3luYyBkZWYgbWxfdG91cm5hbWVudF9yZXN1bHRzKCk6CiAgICAiIiJHZXQgbGF0ZXN0IHRvdXJuYW1lbnQgcmVzdWx0cy4iIiIKICAgIHByb2plY3Rfcm9vdCA9IFBhdGgoX19maWxlX18pLnBhcmVudAogICAgcmVzdWx0c19wYXRoID0gcHJvamVjdF9yb290IC8gIm1sIiAvICJtb2RlbHMiIC8gImNoZWNrcG9pbnRzIiAvICJ0b3VybmFtZW50X3Jlc3VsdHMuanNvbiIKICAgIGlmIHJlc3VsdHNfcGF0aC5leGlzdHMoKToKICAgICAgICB3aXRoIG9wZW4ocmVzdWx0c19wYXRoLCAiciIpIGFzIGY6CiAgICAgICAgICAgIHJldHVybiBqc29uLmxvYWQoZikKICAgIHJldHVybiB7ImVycm9yIjogIk5vIHRvdXJuYW1lbnQgcmVzdWx0cyBmb3VuZC4gUnVuIGEgdG91cm5hbWVudCBmaXJzdC4ifQoKCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCiMgU3RhdGljIEZpbGUgU2VydmluZyAoUmVhY3QgU1BBKSDigJQgTVVTVCBiZSBhZnRlciBhbGwgQVBJIHJvdXRlcwojIOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAoKIyBVSSBTZXJ2aW5nOiBsZWdhY3kgSFRNTC9KUy9DU1MgcGFnZXMgKHByaW1hcnkpIG9yIFJlYWN0IFNQQSAoZmFsbGJhY2spCl9sZWdhY3lfdWlfZGlyID0gUGF0aChfX2ZpbGVfXykucGFyZW50IC8gInVpIgpfc3BhX2RpciA9IFBhdGgoX19maWxlX18pLnBhcmVudCAvICJmcm9udGVuZCIgLyAi' +
'Y29tbWFuZGVyLWFpLWxhYi11aSIgLyAiZGlzdCIKCmlmIF9sZWdhY3lfdWlfZGlyLmV4aXN0cygpOgogICAgIyBQcmltYXJ5OiBzZXJ2ZSB0aGUgcHJvdmVuIG11bHRpLXBhZ2UgSFRNTC9KUy9DU1MgVUkKICAgIGFwcC5tb3VudCgiLyIsIFN0YXRpY0ZpbGVzKGRpcmVjdG9yeT1zdHIoX2xlZ2FjeV91aV9kaXIpLCBodG1sPVRydWUpLCBuYW1lPSJ1aSIpCgplbGlmIF9zcGFfZGlyLmV4aXN0cygpOgogICAgIyBGYWxsYmFjazogUmVhY3QgU1BBIChvbmx5IGlmIGxlZ2FjeSB1aS8gZm9sZGVyIGlzIG1pc3NpbmcpCiAgICBfc3BhX2Fzc2V0cyA9IF9zcGFfZGlyIC8gImFzc2V0cyIKICAgIGlmIF9zcGFfYXNzZXRzLmV4aXN0cygpOgogICAgICAgIGFwcC5tb3VudCgiL2Fzc2V0cyIsIFN0YXRpY0ZpbGVzKGRpcmVjdG9yeT1zdHIoX3NwYV9hc3NldHMpKSwgbmFtZT0ic3BhLWFzc2V0cyIpCgogICAgQGFwcC5nZXQoIi97ZnVsbF9wYXRoOnBhdGh9IikKICAgIGFzeW5jIGRlZiBfc3BhX2NhdGNoYWxsKGZ1bGxfcGF0aDogc3RyKToKICAgICAgICBmcm9tIGZhc3RhcGkucmVzcG9uc2VzIGltcG9ydCBGaWxlUmVzcG9uc2UKICAgICAgICByZXF1ZXN0ZWRfZmlsZSA9IF9zcGFfZGlyIC8gZnVsbF9wYXRoCiAgICAgICAgaWYgZnVsbF9wYXRoIGFuZCByZXF1ZXN0ZWRfZmlsZS5leGlzdHMoKSBhbmQgcmVxdWVzdGVkX2ZpbGUuaXNfZmlsZSgpOgogICAgICAgICAgICByZXR1cm4gRmlsZVJlc3BvbnNlKHN0cihyZXF1ZXN0ZWRfZmlsZSkpCiAgICAgICAgcmV0dXJuIEZpbGVSZXNwb25zZShzdHIoX3NwYV9kaXIgLyAiaW5kZXguaHRtbCIpKQoKCiMg4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQ4pWQCiMgU3RhcnR1cAojIOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAoKZGVmIHBhcnNlX2FyZ3MoKToKICAgIHBhcnNlciA9IGFyZ3BhcnNlLkFyZ3VtZW50UGFyc2VyKGRlc2NyaXB0aW9uPSJDb21tYW5kZXIgQUkgTGFiIEFQSSBTZXJ2ZXIgdjMiKQogICAgcGFyc2VyLmFkZF9hcmd1bWVudCgiLS1mb3JnZS1qYXIiLCBkZWZhdWx0PW9zLmVudmlyb24uZ2V0KCJGT1JHRV9KQVIiLCAiIiksCiAgICAgICAgICAgICAgICAgICAgICAgIGhlbHA9IlBhdGggdG8gZm9yZ2UtZ3VpLWRlc2t0b3AgamFyLXdpdGgtZGVwZW5kZW5jaWVzIikKICAgIHBhcnNlci5hZGRfYXJndW1lbnQoIi0tZm9yZ2UtZGlyIiwgZGVmYXVsdD1vcy5lbnZpcm9uLmdldCgiRk9SR0VfRElSIiwgIiIpLAogICAgICAgICAgICAgICAgICAgICAgICBoZWxwPSJGb3JnZSB3b3JraW5nIGRpcmVjdG9yeSAoY29udGFpbnMgcmVzLykiKQogICAgcGFyc2VyLmFkZF9hcmd1bWVudCgiLS1mb3JnZS1kZWNrcy1kaXIiLCBkZWZhdWx0PW9zLmVudmlyb24uZ2V0KCJGT1JHRV9ERUNLU19ESVIiLCAiIiksCiAgICAgICAgICAgICAgICAgICAgICAgIGhlbHA9IlBhdGggdG8gQ29tbWFuZGVyIGRlY2sgZmlsZXMgKGRlZmF1bHQ6ICUlQVBQREFUQSUlL0ZvcmdlL2RlY2tzL2NvbW1hbmRlcikiKQogICAgcGFyc2VyLmFkZF9hcmd1bWVudCgiLS1sYWItamFyIiwgZGVmYXVsdD1vcy5lbnZpcm9uLmdldCgiTEFCX0pBUiIsICIiKSwKICAgICAgICAgICAgICAgICAgICAgICAgaGVscD0iUGF0aCB0byBjb21tYW5kZXItYWktbGFiLmphciAoZGVmYXVsdDogYXV0by1kZXRlY3QgZnJvbSB0YXJnZXQvKSIpCiAgICBwYXJzZXIuYWRkX2FyZ3VtZW50KCItLXBvcnQiLCB0eXBlPWludCwgZGVmYXVsdD1pbnQob3MuZW52aXJvbi5nZXQoIkxBQl9QT1JUIiwgIjgwODAiKSkpCiAgICBwYXJzZXIuYWRkX2FyZ3VtZW50KCItLXhpbWlsYXIta2V5IiwgZGVmYXVsdD1vcy5lbnZpcm9uLmdldCgiWElNSUxBUl9BUElfS0VZIiwgIjk2YzdkYWIzNWRkYmQ4ODI5YjA0YzBmNWJjZWE1N2Y1ZWRlMjA0OTYiKSwKICAgICAgICAgICAgICAgICAgICAgICAgaGVscD0iWGltaWxhciBB' +
'UEkga2V5IGZvciBjYXJkIHNjYW5uZXIgKHZpc3VhbCBBSSByZWNvZ25pdGlvbikiKQogICAgcGFyc2VyLmFkZF9hcmd1bWVudCgiLS1wcGx4LWtleSIsIGRlZmF1bHQ9b3MuZW52aXJvbi5nZXQoIlBQTFhfQVBJX0tFWSIsICJwcGx4LUc3NkhnckFVOEltNzJiRVRNZXlSNGFzQVd3dEc4d3JteWZ5NlZTS0E5RFRuMDVGcSIpLAogICAgICAgICAgICAgICAgICAgICAgICBoZWxwPSJQZXJwbGV4aXR5IEFQSSBrZXkgZm9yIEFJIGRlY2sgcmVzZWFyY2gvZ2VuZXJhdGlvbiAoZW52OiBQUExYX0FQSV9LRVkpIikKICAgIHJldHVybiBwYXJzZXIucGFyc2VfYXJncygpCgoKZGVmIHJlc29sdmVfbGFiX2phcigpIC0+IHN0cjoKICAgIHRhcmdldF9kaXIgPSBQYXRoKF9fZmlsZV9fKS5wYXJlbnQgLyAidGFyZ2V0IgogICAgaWYgdGFyZ2V0X2Rpci5leGlzdHMoKToKICAgICAgICBmb3IgcGF0dGVybiBpbiBbCiAgICAgICAgICAgICJjb21tYW5kZXItYWktbGFiLSotamFyLXdpdGgtZGVwZW5kZW5jaWVzLmphciIsCiAgICAgICAgICAgICJjb21tYW5kZXItYWktbGFiLSotc2hhZGVkLmphciIsCiAgICAgICAgICAgICJjb21tYW5kZXItYWktbGFiLSouamFyIiwKICAgICAgICBdOgogICAgICAgICAgICBqYXJzID0gc29ydGVkKHRhcmdldF9kaXIuZ2xvYihwYXR0ZXJuKSkKICAgICAgICAgICAgamFycyA9IFtqIGZvciBqIGluIGphcnMgaWYgbm90IGoubmFtZS5zdGFydHN3aXRoKCJvcmlnaW5hbC0iKV0KICAgICAgICAgICAgaWYgamFyczoKICAgICAgICAgICAgICAgIHJldHVybiBzdHIoamFyc1swXSkKICAgIHJldHVybiAiIgoKCmRlZiByZXNvbHZlX2ZvcmdlX2RlY2tzX2RpcigpIC0+IHN0cjoKICAgIGlmIHN5cy5wbGF0Zm9ybSA9PSAid2luMzIiOgogICAgICAgIGFwcGRhdGEgPSBvcy5lbnZpcm9uLmdldCgiQVBQREFUQSIsICIiKQogICAgICAgIGlmIGFwcGRhdGE6CiAgICAgICAgICAgIGNhbmRpZGF0ZSA9IG9zLnBhdGguam9pbihhcHBkYXRhLCAiRm9yZ2UiLCAiZGVja3MiLCAiY29tbWFuZGVyIikKICAgICAgICAgICAgaWYgb3MucGF0aC5pc2RpcihjYW5kaWRhdGUpOgogICAgICAgICAgICAgICAgcmV0dXJuIGNhbmRpZGF0ZQogICAgaG9tZSA9IFBhdGguaG9tZSgpCiAgICBmb3IgY2FuZGlkYXRlIGluIFsKICAgICAgICBob21lIC8gIi5mb3JnZSIgLyAiZGVja3MiIC8gImNvbW1hbmRlciIsCiAgICAgICAgaG9tZSAvICJGb3JnZSIgLyAiZGVja3MiIC8gImNvbW1hbmRlciIsCiAgICBdOgogICAgICAgIGlmIGNhbmRpZGF0ZS5pc19kaXIoKToKICAgICAgICAgICAgcmV0dXJuIHN0cihjYW5kaWRhdGUpCiAgICByZXR1cm4gIiIKCgpkZWYgbG9hZF9jb21tYW5kZXJfbWV0YSgpOgogICAgIiIiTG9hZCBjb21tYW5kZXIgbWV0YSBtYXBwaW5nIGZyb20gZmlsZSBvciB1c2UgYnVpbHRpbnMuIiIiCiAgICBnbG9iYWwgQ09NTUFOREVSX01FVEEKICAgIG1ldGFfcGF0aCA9IFBhdGgoX19maWxlX18pLnBhcmVudCAvICJjb21tYW5kZXItbWV0YS5qc29uIgogICAgaWYgbWV0YV9wYXRoLmV4aXN0cygpOgogICAgICAgIHRyeToKICAgICAgICAgICAgd2l0aCBvcGVuKG1ldGFfcGF0aCwgInIiLCBlbmNvZGluZz0idXRmLTgiKSBhcyBmOgogICAgICAgICAgICAgICAgQ09NTUFOREVSX01FVEEgPSBqc29uLmxvYWQoZikKICAgICAgICAgICAgcHJpbnQoZiIgIE1ldGE6ICAgICAgICAgTG9hZGVkIHtsZW4oQ09NTUFOREVSX01FVEEpfSBjb21tYW5kZXJzIGZyb20ge21ldGFfcGF0aH0iKQogICAgICAgICAgICByZXR1cm4KICAgICAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgICAgIHByaW50KGYiICBXQVJOSU5HOiBGYWlsZWQgdG8gbG9hZCBjb21tYW5kZXItbWV0YS5qc29uOiB7ZX0iKQoKICAgIENPTU1BTkRFUl9NRVRBID0gQlVJTFRJTl9DT01NQU5ERVJTCiAgICBwcmludChmIiAgTWV0YTogICAgICAgICB7bGVuKENPTU1BTkRFUl9NRVRBKX0gYnVpbHQtaW4gY29tbWFuZGVycyIpCgoKZGVmIG1haW4oKToKICAgIGFyZ3MgPSBwYXJzZV9hcmdzKCkKCiAgICBDRkcuZm9yZ2VfamFyID0gYXJncy5mb3JnZV9qYXIKICAgIENGRy5mb3JnZV9kaXIgPSBhcmdzLmZvcmdlX2RpcgogICAgQ0ZHLmZvcmdlX2RlY2tzX2RpciA9IGFyZ3MuZm9yZ2VfZGVja3NfZGlyIG9yIHJlc29sdmVfZm9yZ2VfZGVja3NfZGlyKCkKICAgIENGRy5sYWJfamFyID0gYXJncy5sYWJfamFyIG9yIHJlc29sdmVfbGFiX2phcigpCiAgICBDRkcucG9ydCA9IGFyZ3MucG9ydAog' +
'ICAgQ0ZHLnhpbWlsYXJfYXBpX2tleSA9IGFyZ3MueGltaWxhcl9rZXkKICAgIENGRy5wcGx4X2FwaV9rZXkgPSBhcmdzLnBwbHhfa2V5CgogICAgcHJpbnQoIuKVlOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVlyIpCiAgICBwcmludCgi4pWRICAgICAgQ29tbWFuZGVyIEFJIExhYiDigJQgQVBJIFNlcnZlciAgdjMuMC4wICAgICAg4pWRIikKICAgIHByaW50KCLilZrilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZDilZ0iKQogICAgcHJpbnQoKQogICAgcHJpbnQoZiIgIEZvcmdlIEpBUjogICAge0NGRy5mb3JnZV9qYXJ9IikKICAgIHByaW50KGYiICBGb3JnZSBEaXI6ICAgIHtDRkcuZm9yZ2VfZGlyfSIpCiAgICBwcmludChmIiAgRGVja3MgRGlyOiAgICB7Q0ZHLmZvcmdlX2RlY2tzX2Rpcn0iKQogICAgcHJpbnQoZiIgIExhYiBKQVI6ICAgICAge0NGRy5sYWJfamFyfSIpCiAgICBwcmludChmIiAgUmVzdWx0cyBEaXI6ICB7Q0ZHLnJlc3VsdHNfZGlyfSIpCiAgICBwcmludChmIiAgUG9ydDogICAgICAgICB7Q0ZHLnBvcnR9IikKICAgIHByaW50KGYiICBYaW1pbGFyOiAgICAgIHsnY29uZmlndXJlZCcgaWYgQ0ZHLnhpbWlsYXJfYXBpX2tleSBlbHNlICdOT1QgU0VUIChzY2FubmVyIHdpbGwgZmFpbCknfSIpCiAgICBwcmludChmIiAgUGVycGxleGl0eTogICB7J2NvbmZpZ3VyZWQnIGlmIENGRy5wcGx4X2FwaV9rZXkgZWxzZSAnTk9UIFNFVCAoQUkgcmVzZWFyY2gvZ2VuIGRpc2FibGVkKSd9IikKICAgIGoxNyA9IGdldF9qYXZhMTcoKQogICAgcHJpbnQoZiIgIEphdmEgMTc6ICAgICAge2oxNyBpZiBqMTcgIT0gJ2phdmEnIGVsc2UgJ05PVCBGT1VORCAoYmF0Y2ggc2ltIG1heSBmYWlsIG9uIEphdmEgMjUrKSd9IikKICAgIHByaW50KGYiICBMTSBTdHVkaW86ICAgIGh0dHA6Ly8xOTIuMTY4LjAuMTIyOjEyMzQiKQoKICAgIGxvYWRfY29tbWFuZGVyX21ldGEoKQogICAgZG93bmxvYWRfcHJlY29uX2RhdGFiYXNlKCkgICMgQXV0by1kb3dubG9hZHMgYWxsIDE2MysgQ29tbWFuZGVyIHByZWNvbnMgb24gZmlyc3QgcnVuCiAgICBpbml0X2NvbGxlY3Rpb25fZGIoKQogICAgaW5pdF9jb2FjaF9zZXJ2aWNlKCkKCiAgICAjIFhpbWlsYXIgQVBJIGtleSBjaGVjawogICAgaWYgbm90IENGRy54aW1pbGFyX2FwaV9rZXk6CiAgICAgICAgcHJpbnQoIiAgV0FSTklORzogLS14aW1pbGFyLWtleSBub3Qgc2V0LiBDYXJkIHNjYW5uZXIgd2lsbCBub3Qgd29yay4iKQogICAgICAgIHByaW50KCIgICAgICAgICAgIFNldCB2aWEgQ0xJOiAtLXhpbWlsYXIta2V5IFlPVVJfS0VZIikKICAgICAgICBwcmludCgiICAgICAgICAgICBPciBlbnYgdmFyOiAgWElNSUxBUl9BUElfS0VZPVlPVVJfS0VZIikKCiAgICBpZiBub3QgQ0ZHLmZvcmdlX2phcjoKICAgICAgICBwcmludCgiV0FSTklORzogLS1mb3JnZS1qYXIgbm90IHNldC4gL2FwaS9sYWIvc3RhcnQgd2lsbCBmYWlsLiIpCiAgICBpZiBub3QgQ0ZHLmxhYl9qYXI6CiAgICAgICAgcHJpbnQoIldBUk5JTkc6IExhYiBKQVIgbm90IGZvdW5kLiBCdWlsZCB3aXRoOiBtdm4gcGFja2FnZSAtRHNraXBUZXN0cyIpCiAgICBpZiBub3QgQ0ZHLmZvcmdlX2RlY2tzX2RpcjoKICAgICAgICBwcmludCgiV0FSTklORzogQ29tbWFuZGVyIGRlY2tzIGRpciBub3QgZm91bmQuIC9hcGkvbGFiL2RlY2tzIHdpbGwgcmV0dXJuIGVtcHR5LiIpCgogICAgcHJpbnQoKQogICAgcHJpbnQoZiIgIFN0YXJ0aW5nIHNlcnZlciBvbiBodHRwOi8vbG9jYWxob3N0OntDRkcucG9ydH0iKQogICAgaWYgX3NwYV9kaXIuZXhpc3RzKCk6CiAgICAgICAgcHJpbnQoZiIgIFdlYiBVSTogICAgICAgaHR0cDovL2xvY2FsaG9zdDp7Q0ZHLnBvcnR9LyAgKFJlYWN0IFNQQSkiKQogICAgICAgIHByaW50KGYiICBSb3V0ZXM6ICAgICAgIC8gKEJhdGNoIFNpbSksIC9jb2xsZWN0aW9uLCAvZGVja3MsIC9hdXRvZ2VuLCAvc2ltdWxhdG9yLCAvY29hY2gsIC90cmFpbmluZyIpCiAgICBlbHNlOgogICAgICAgIHByaW50KGYi' +
'ICBXZWIgVUk6ICAgICAgIGh0dHA6Ly9sb2NhbGhvc3Q6e0NGRy5wb3J0fS9pbmRleC5odG1sICAobGVnYWN5IEhUTUwpIikKICAgICAgICBwcmludChmIiAgTk9URTogUmVhY3QgU1BBIG5vdCBidWlsdC4gUnVuOiBjZCBmcm9udGVuZC9jb21tYW5kZXItYWktbGFiLXVpICYmIG5wbSBpbnN0YWxsICYmIG5wbSBydW4gYnVpbGQiKQogICAgcHJpbnQoZiIgIEFQSSBkb2NzOiAgICAgaHR0cDovL2xvY2FsaG9zdDp7Q0ZHLnBvcnR9L2RvY3MiKQogICAgcHJpbnQoKQoKICAgIGltcG9ydCB1dmljb3JuCiAgICB1dmljb3JuLnJ1bihhcHAsIGhvc3Q9IjAuMC4wLjAiLCBwb3J0PUNGRy5wb3J0LCBsb2dfbGV2ZWw9ImluZm8iKQoKCmlmIF9fbmFtZV9fID09ICJfX21haW5fXyI6CiAgICBtYWluKCkK'
Install-B64 -R 'lab_api.py' -B $b
# ml/__init__.py (0 bytes)
@@ -1374,25 +1370,25 @@ $b = 'PCFET0NUWVBFIGh0bWw+CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ+CiAgICA8bWV0YSBjaGFyc2
'IiBzdHlsZT0iZGlzcGxheTpub25lOyI+CiAgICAgICAgICAgICAgICA8ZGl2IGNsYXNzPSJwYW5lbC1oZWFkZXIiPgogICAgICAgICAgICAgICAgICAgIDxoMj5NYW5hIEJhc2UgQWR2aWNlPC9oMj4KICAgICAgICAgICAgICAgIDwvZGl2PgogICAgICAgICAgICAgICAgPHAgaWQ9ImNvYWNoLW1hbmEtdGV4dCIgY2xhc3M9ImNvYWNoLW1hbmEtdGV4dCI+PC9wPgogICAgICAgICAgICA8L2Rpdj4KCiAgICAgICAgICAgIDwhLS0gUmF3IEV4cGxhbmF0aW9uIChjb2xsYXBzaWJsZSkgLS0+CiAgICAgICAgICAgIDxkZXRhaWxzIGNsYXNzPSJwYW5lbCBjb2FjaC1yYXctZGV0YWlscyI+CiAgICAgICAgICAgICAgICA8c3VtbWFyeT5GdWxsIExMTSBSZXNwb25zZTwvc3VtbWFyeT4KICAgICAgICAgICAgICAgIDxwcmUgaWQ9ImNvYWNoLXJhdy10ZXh0IiBjbGFzcz0iY29hY2gtcmF3LXRleHQiPjwvcHJlPgogICAgICAgICAgICA8L2RldGFpbHM+CiAgICAgICAgPC9zZWN0aW9uPgoKICAgICAgICA8IS0tIFBhc3QgU2Vzc2lvbnMgLS0+CiAgICAgICAgPHNlY3Rpb24gY2xhc3M9InBhbmVsIGNvYWNoLXNlc3Npb25zLXNlY3Rpb24iPgogICAgICAgICAgICA8ZGl2IGNsYXNzPSJwYW5lbC1oZWFkZXIiPgogICAgICAgICAgICAgICAgPGgyPlBhc3QgU2Vzc2lvbnM8L2gyPgogICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgPGRpdiBpZD0iY29hY2gtc2Vzc2lvbnMtbGlzdCIgY2xhc3M9ImNvYWNoLXNlc3Npb25zLWxpc3QiPgogICAgICAgICAgICAgICAgPHAgY2xhc3M9ImNvYWNoLWVtcHR5Ij5ObyBjb2FjaGluZyBzZXNzaW9ucyB5ZXQuIFJ1biB0aGUgY29hY2ggdG8gZ2V0IHN0YXJ0ZWQuPC9wPgogICAgICAgICAgICA8L2Rpdj4KICAgICAgICA8L3NlY3Rpb24+CgogICAgICAgIDwhLS0gVG9hc3QgQ29udGFpbmVyIC0tPgogICAgICAgIDxkaXYgY2xhc3M9ImNvYWNoLXRvYXN0LWNvbnRhaW5lciIgaWQ9ImNvYWNoLXRvYXN0LWNvbnRhaW5lciI+PC9kaXY+CiAgICA8L2Rpdj4KCiAgICA8c2NyaXB0IHNyYz0iY29hY2guanMiPjwvc2NyaXB0Pgo8L2JvZHk+CjwvaHRtbD4K'
Install-B64 -R 'ui\coach.html' -B $b
# ui/coach.js (40,252 bytes)
# ui/coach.js (39,376 bytes)
$b = 'LyoqCiAqIENvbW1hbmRlciBBSSBMYWIg4oCUIERlY2sgQ29hY2ggVUkgdjI4CiAqIOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkOKVkAogKiBNdWx0aS10dXJuIGNoYXQgd2l0aCBTU0Ugc3RyZWFtaW5nLCBhcHBseSBzdWdnZXN0aW9ucywKICogY2FyZHMtbGlrZSBzZWFyY2gsIGVuaGFuY2VkIHNlc3Npb24gaGlzdG9yeS4KICovCgooZnVuY3Rpb24gKCkgewogICAgJ3VzZSBzdHJpY3QnOwoKICAgIGNvbnN0IEFQSSA9IHdpbmRvdy5sb2NhdGlvbi5vcmlnaW47CgogICAgLy8g4pSA4pSAIFN0YXRlIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAogICAgbGV0IGNoYXRIaXN0b3J5ID0gW107ICAgICAgIC8vIFt7cm9sZSwgY29udGVudH1dCiAgICBsZXQgaXNTdHJlYW1pbmcgPSBmYWxzZTsKICAgIGxldCBjdXJyZW50U2Vzc2lvbklkID0gbnVsbDsKICAgIGxldCBzZXNzaW9uQ2FjaGUgPSB7fTsgICAgICAvLyBzZXNzaW9uSWQg4oaSIGZ1bGwgc2Vzc2lvbiBkYXRhCiAgICBsZXQgZXhwYW5kZWRTZXNzaW9ucyA9IHt9OyAgLy8gc2Vzc2lvbklkIOKGkiBib29sCiAgICBsZXQgZGJEZWNrcyA9IFtdOyAgICAgICAgICAgLy8gW3tpZCwgbmFtZX1dIGZyb20gL2FwaS9kZWNrcwoKICAgIC8vIOKUgOKUgCBET00gUmVmZXJlbmNlcyDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKICAgIGNvbnN0IGRlY2tTZWxlY3QgICAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtZGVjay1zZWxlY3QnKTsKICAgIGNvbnN0IHBvd2VyU2xpZGVyICAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtcG93ZXItbGV2ZWwnKTsKICAgIGNvbnN0IHBvd2VyVmFsdWUgICAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtcG93ZXItdmFsdWUnKTsKICAgIGNvbnN0IG1ldGFGb2N1cyAgICAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtbWV0YS1mb2N1cycpOwogICAgY29uc3QgYnVkZ2V0U2VsZWN0ICAgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjb2FjaC1idWRnZXQnKTsKICAgIGNvbnN0IHJ1bkJ0biAgICAgICAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtcnVuLWJ0bicpOwogICAgY29uc3QgcnVuSGludCAgICAgICAgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjb2FjaC1ydW4taGludCcpOwogICAgY29uc3QgcHJvZ3Jlc3NFbCAgICAgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjb2FjaC1wcm9ncmVzcycpOwogICAgY29uc3QgcHJvZ3Jlc3NUZXh0ICAgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjb2FjaC1wcm9ncmVzcy10ZXh0Jyk7CiAgICBjb25zdCByZXN1bHRzRWwgICAgICAgID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2NvYWNoLXJlc3VsdHMnKTsKCiAgICAvLyBTdGF0dXMKICAgIGNvbnN0IHN0YXR1c0xsbURvdCAgICAgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCcjc3RhdHVzLWxsbSAuc3RhdHVzLWRvdCcpOwogICAgY29uc3Qgc3RhdHVzTGxtVmFsdWUgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzdGF0dXMtbGxtLXZhbHVlJyk7CiAgICBjb25zdCBzdGF0dXNFbWJEb3QgICAgID0gZG9jdW1lbnQucXVlcnlTZWxlY3RvcignI3N0YXR1cy1lbWJlZGRpbmdzIC5zdGF0dXMtZG90Jyk7CiAgICBjb25zdCBzdGF0dXNFbWJWYWx1ZSAgID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3N0YXR1cy1lbWJlZGRpbmdzLXZhbHVlJyk7CiAgICBjb25zdCBzdGF0dXNSZXBEb3QgICAgID0gZG9jdW1lbnQucXVlcnlTZWxlY3RvcignI3N0YXR1cy1yZXBvcnRzIC5zdGF0dXMtZG90Jyk7CiAgICBjb25zdCBzdGF0dXNSZXBWYWx1ZSAgID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3N0YXR1cy1yZXBvcnRzLXZhbHVlJyk7CgogICAgLy8gUmVzdWx0cwogICAgY29uc3Qgc3VtbWFyeVRleHQgICAgICA9IGRvY3VtZW50LmdldEVsZW1l' +
'bnRCeUlkKCdjb2FjaC1zdW1tYXJ5LXRleHQnKTsKICAgIGNvbnN0IG1ldGFSb3cgICAgICAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtbWV0YS1yb3cnKTsKICAgIGNvbnN0IGN1dHNDb3VudCAgICAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtY3V0cy1jb3VudCcpOwogICAgY29uc3QgY3V0c0JvZHkgICAgICAgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjb2FjaC1jdXRzLWJvZHknKTsKICAgIGNvbnN0IGN1dHNBbGxDaGVjayAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtY3V0cy1hbGwnKTsKICAgIGNvbnN0IGFkZHNDb3VudCAgICAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtYWRkcy1jb3VudCcpOwogICAgY29uc3QgYWRkc0JvZHkgICAgICAgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjb2FjaC1hZGRzLWJvZHknKTsKICAgIGNvbnN0IGFkZHNBbGxDaGVjayAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtYWRkcy1hbGwnKTsKICAgIGNvbnN0IGhpbnRzQ2FyZCAgICAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtaGludHMtY2FyZCcpOwogICAgY29uc3QgaGludHNMaXN0ICAgICAgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjb2FjaC1oaW50cy1saXN0Jyk7CiAgICBjb25zdCBtYW5hQ2FyZCAgICAgICAgID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2NvYWNoLW1hbmEtY2FyZCcpOwogICAgY29uc3QgbWFuYVRleHQgICAgICAgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjb2FjaC1tYW5hLXRleHQnKTsKICAgIGNvbnN0IHJhd1RleHQgICAgICAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtcmF3LXRleHQnKTsKICAgIGNvbnN0IHNlc3Npb25zTGlzdCAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtc2Vzc2lvbnMtbGlzdCcpOwogICAgY29uc3QgZ2VuUmVwb3J0c0J0biAgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdnZW5lcmF0ZS1yZXBvcnRzLWJ0bicpOwoKICAgIC8vIENoYXQKICAgIGNvbnN0IGNoYXRNZXNzYWdlcyAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtY2hhdC1tZXNzYWdlcycpOwogICAgY29uc3QgY2hhdElucHV0ICAgICAgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjb2FjaC1jaGF0LWlucHV0Jyk7CiAgICBjb25zdCBjaGF0U2VuZEJ0biAgICAgID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2NvYWNoLWNoYXQtc2VuZCcpOwogICAgY29uc3QgY2hhdENsZWFyQnRuICAgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjb2FjaC1jaGF0LWNsZWFyJyk7CgogICAgLy8gQ2FyZHMtTGlrZQogICAgY29uc3QgY2FyZHNsaWtlSW5wdXQgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjYXJkc2xpa2UtaW5wdXQnKTsKICAgIGNvbnN0IGNhcmRzbGlrZVRvcG4gICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY2FyZHNsaWtlLXRvcG4nKTsKICAgIGNvbnN0IGNhcmRzbGlrZVNlYXJjaCAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY2FyZHNsaWtlLXNlYXJjaC1idG4nKTsKICAgIGNvbnN0IGNhcmRzbGlrZVJlc3VsdHMgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY2FyZHNsaWtlLXJlc3VsdHMnKTsKICAgIGNvbnN0IGNhcmRzbGlrZUhlYWRlciAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY2FyZHNsaWtlLWhlYWRlcicpOwogICAgY29uc3QgY2FyZHNsaWtlQm9keSAgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjYXJkc2xpa2UtYm9keScpOwogICAgY29uc3QgY2FyZHNsaWtlQXJyb3cgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjYXJkc2xpa2UtYXJyb3cnKTsKCiAgICAvLyBBcHBseQogICAgY29uc3QgYXBwbHlCdG4gICAgICAgICA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdjb2FjaC1hcHBseS1idG4nKTsKICAgIGNvbnN0IGFwcGx5RGVja1NlbGVjdCAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtYXBwbHktZGVjay1zZWxlY3QnKTsKICAgIGNvbnN0IGFwcGx5Q291bnRFbCAgICAgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtYXBwbHktY291bnQnKTsKCiAgICAvLyDilIDilIAgSW5pdCDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDi' +
'lIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKCiAgICBpbml0KCk7CgogICAgYXN5bmMgZnVuY3Rpb24gaW5pdCgpIHsKICAgICAgICBwb3dlclNsaWRlci5hZGRFdmVudExpc3RlbmVyKCdpbnB1dCcsICgpID0+IHsKICAgICAgICAgICAgcG93ZXJWYWx1ZS50ZXh0Q29udGVudCA9IHBvd2VyU2xpZGVyLnZhbHVlOwogICAgICAgIH0pOwoKICAgICAgICBkZWNrU2VsZWN0LmFkZEV2ZW50TGlzdGVuZXIoJ2NoYW5nZScsICgpID0+IHsKICAgICAgICAgICAgY29uc3QgaGFzRGVjayA9IGRlY2tTZWxlY3QudmFsdWUgIT09ICcnOwogICAgICAgICAgICBydW5CdG4uZGlzYWJsZWQgPSAhaGFzRGVjazsKICAgICAgICAgICAgY2hhdFNlbmRCdG4uZGlzYWJsZWQgPSAhaGFzRGVjazsKICAgICAgICAgICAgcnVuSGludC50ZXh0Q29udGVudCA9IGhhc0RlY2sgPyAnUmVhZHkgdG8gYW5hbHl6ZScgOiAnU2VsZWN0IGEgZGVjayB0byBiZWdpbic7CiAgICAgICAgfSk7CgogICAgICAgIHJ1bkJ0bi5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsIHJ1bkNvYWNoKTsKICAgICAgICBnZW5SZXBvcnRzQnRuLmFkZEV2ZW50TGlzdGVuZXIoJ2NsaWNrJywgZ2VuZXJhdGVSZXBvcnRzKTsKCiAgICAgICAgY3V0c0FsbENoZWNrLmFkZEV2ZW50TGlzdGVuZXIoJ2NoYW5nZScsICgpID0+IHsKICAgICAgICAgICAgY3V0c0JvZHkucXVlcnlTZWxlY3RvckFsbCgnaW5wdXRbdHlwZT0iY2hlY2tib3giXScpLmZvckVhY2goY2IgPT4gewogICAgICAgICAgICAgICAgY2IuY2hlY2tlZCA9IGN1dHNBbGxDaGVjay5jaGVja2VkOwogICAgICAgICAgICB9KTsKICAgICAgICAgICAgdXBkYXRlQXBwbHlDb3VudCgpOwogICAgICAgIH0pOwogICAgICAgIGFkZHNBbGxDaGVjay5hZGRFdmVudExpc3RlbmVyKCdjaGFuZ2UnLCAoKSA9PiB7CiAgICAgICAgICAgIGFkZHNCb2R5LnF1ZXJ5U2VsZWN0b3JBbGwoJ2lucHV0W3R5cGU9ImNoZWNrYm94Il0nKS5mb3JFYWNoKGNiID0+IHsKICAgICAgICAgICAgICAgIGNiLmNoZWNrZWQgPSBhZGRzQWxsQ2hlY2suY2hlY2tlZDsKICAgICAgICAgICAgfSk7CiAgICAgICAgICAgIHVwZGF0ZUFwcGx5Q291bnQoKTsKICAgICAgICB9KTsKCiAgICAgICAgLy8gQ2hhdCBldmVudHMKICAgICAgICBjaGF0U2VuZEJ0bi5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsIHNlbmRDaGF0TWVzc2FnZSk7CiAgICAgICAgY2hhdElucHV0LmFkZEV2ZW50TGlzdGVuZXIoJ2tleWRvd24nLCAoZSkgPT4gewogICAgICAgICAgICBpZiAoZS5rZXkgPT09ICdFbnRlcicgJiYgIWUuc2hpZnRLZXkpIHsKICAgICAgICAgICAgICAgIGUucHJldmVudERlZmF1bHQoKTsKICAgICAgICAgICAgICAgIHNlbmRDaGF0TWVzc2FnZSgpOwogICAgICAgICAgICB9CiAgICAgICAgfSk7CiAgICAgICAgY2hhdElucHV0LmFkZEV2ZW50TGlzdGVuZXIoJ2lucHV0JywgKCkgPT4gewogICAgICAgICAgICBjaGF0U2VuZEJ0bi5kaXNhYmxlZCA9ICFjaGF0SW5wdXQudmFsdWUudHJpbSgpIHx8ICFkZWNrU2VsZWN0LnZhbHVlOwogICAgICAgIH0pOwogICAgICAgIGNoYXRDbGVhckJ0bi5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsIGNsZWFyQ2hhdCk7CgogICAgICAgIC8vIENhcmRzLUxpa2UKICAgICAgICBjYXJkc2xpa2VTZWFyY2guYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCBzZWFyY2hDYXJkc0xpa2UpOwogICAgICAgIGNhcmRzbGlrZUlucHV0LmFkZEV2ZW50TGlzdGVuZXIoJ2tleWRvd24nLCAoZSkgPT4gewogICAgICAgICAgICBpZiAoZS5rZXkgPT09ICdFbnRlcicpIHNlYXJjaENhcmRzTGlrZSgpOwogICAgICAgIH0pOwogICAgICAgIGNhcmRzbGlrZUhlYWRlci5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsICgpID0+IHsKICAgICAgICAgICAgY2FyZHNsaWtlQm9keS5jbGFzc0xpc3QudG9nZ2xlKCdjb2xsYXBzZWQnKTsKICAgICAgICAgICAgY2FyZHNsaWtlQXJyb3cudGV4dENvbnRlbnQgPSBjYXJkc2xpa2VCb2R5LmNsYXNzTGlzdC5jb250YWlucygnY29sbGFwc2VkJykgPyAnXHUyNUI2JyA6ICdcdTI1QkMnOwogICAgICAgIH0pOwoKICAgICAgICAvLyBBcHBseQogICAgICAgIGFwcGx5QnRuLmFkZEV2ZW50TGlzdGVuZXIoJ2NsaWNrJywgYXBwbHlTdWdnZXN0aW9ucyk7CgogICAgICAgIC8vIERlbGVnYXRlIGNoZWNrYm94IGNoYW5nZXMgaW4gdGFibGVzIGZvciBhcHBseSBjb3VudAogICAgICAgIGRvY3VtZW50' +
'LmdldEVsZW1lbnRCeUlkKCdjb2FjaC1jdXRzLWNhcmQnKS5hZGRFdmVudExpc3RlbmVyKCdjaGFuZ2UnLCB1cGRhdGVBcHBseUNvdW50KTsKICAgICAgICBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY29hY2gtYWRkcy1jYXJkJykuYWRkRXZlbnRMaXN0ZW5lcignY2hhbmdlJywgdXBkYXRlQXBwbHlDb3VudCk7CgogICAgICAgIC8vIExvYWQgZGF0YQogICAgICAgIGF3YWl0IFByb21pc2UuYWxsKFsKICAgICAgICAgICAgY2hlY2tTdGF0dXMoKSwKICAgICAgICAgICAgbG9hZERlY2tzKCksCiAgICAgICAgICAgIGxvYWRTZXNzaW9ucygpLAogICAgICAgICAgICBsb2FkRGJEZWNrcygpLAogICAgICAgIF0pOwogICAgfQoKICAgIC8vIOKUgOKUgCBHb2FscyBIZWxwZXIg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgZnVuY3Rpb24gZ2V0R29hbHMoKSB7CiAgICAgICAgY29uc3QgZ29hbHMgPSB7fTsKICAgICAgICBjb25zdCBwbCA9IHBhcnNlSW50KHBvd2VyU2xpZGVyLnZhbHVlKTsKICAgICAgICBpZiAocGwpIGdvYWxzLnRhcmdldFBvd2VyTGV2ZWwgPSBwbDsKICAgICAgICBpZiAobWV0YUZvY3VzLnZhbHVlKSBnb2Fscy5tZXRhRm9jdXMgPSBtZXRhRm9jdXMudmFsdWU7CiAgICAgICAgaWYgKGJ1ZGdldFNlbGVjdC52YWx1ZSkgZ29hbHMuYnVkZ2V0ID0gYnVkZ2V0U2VsZWN0LnZhbHVlOwogICAgICAgIGNvbnN0IGZvY3VzQXJlYXMgPSBbXTsKICAgICAgICBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcuY29hY2gtZm9jdXMtdGFncyBpbnB1dFt0eXBlPSJjaGVja2JveCJdOmNoZWNrZWQnKS5mb3JFYWNoKGNiID0+IHsKICAgICAgICAgICAgZm9jdXNBcmVhcy5wdXNoKGNiLnZhbHVlKTsKICAgICAgICB9KTsKICAgICAgICBpZiAoZm9jdXNBcmVhcy5sZW5ndGggPiAwKSBnb2Fscy5mb2N1c0FyZWFzID0gZm9jdXNBcmVhczsKICAgICAgICByZXR1cm4gZ29hbHM7CiAgICB9CgogICAgLy8g4pSA4pSAIFN0YXR1cyBDaGVjayDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKCiAgICBhc3luYyBmdW5jdGlvbiBjaGVja1N0YXR1cygpIHsKICAgICAgICB0cnkgewogICAgICAgICAgICBjb25zdCByZXMgPSBhd2FpdCBmZXRjaChBUEkgKyAnL2FwaS9jb2FjaC9zdGF0dXMnKTsKICAgICAgICAgICAgY29uc3QgZGF0YSA9IGF3YWl0IHJlcy5qc29uKCk7CgogICAgICAgICAgICBpZiAoZGF0YS5sbG1Db25uZWN0ZWQpIHsKICAgICAgICAgICAgICAgIHNldFN0YXR1cyhzdGF0dXNMbG1Eb3QsIHN0YXR1c0xsbVZhbHVlLCAnb2snLCBkYXRhLmxsbU1vZGVsIHx8ICdDb25uZWN0ZWQnKTsKICAgICAgICAgICAgfSBlbHNlIHsKICAgICAgICAgICAgICAgIHNldFN0YXR1cyhzdGF0dXNMbG1Eb3QsIHN0YXR1c0xsbVZhbHVlLCAnZXJyb3InLCBkYXRhLmVycm9yIHx8ICdOb3QgY29ubmVjdGVkJyk7CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGlmIChkYXRhLmVtYmVkZGluZ3NMb2FkZWQpIHsKICAgICAgICAgICAgICAgIHNldFN0YXR1cyhzdGF0dXNFbWJEb3QsIHN0YXR1c0VtYlZhbHVlLCAnb2snLCBkYXRhLmVtYmVkZGluZ0NhcmRzLnRvTG9jYWxlU3RyaW5nKCkgKyAnIGNhcmRzJyk7CiAgICAgICAgICAgIH0gZWxzZSB7CiAgICAgICAgICAgICAgICBzZXRTdGF0dXMoc3RhdHVzRW1iRG90LCBzdGF0dXNFbWJWYWx1ZSwgJ3dhcm4nLCAnTm90IGxvYWRlZCcpOwogICAgICAgICAgICB9CgogICAgICAgICAgICBpZiAoZGF0YS5kZWNrUmVwb3J0c0F2YWlsYWJsZSA+IDApIHsKICAgICAgICAgICAgICAgIHNldFN0YXR1cyhzdGF0dXNSZXBEb3QsIHN0YXR1c1JlcFZhbHVlLCAnb2snLCBkYXRhLmRlY2tSZXBvcnRzQXZhaWxhYmxlICsgJyBhdmFpbGFibGUnKTsKICAgICAgICAgICAgICAgIGdlblJlcG9ydHNCdG4uc3R5bGUuZGlzcGxheSA9ICdpbmxpbmUtYmxvY2snOwogICAgICAgICAgICAgICAgZ2VuUmVwb3J0c0J0bi50ZXh0Q29udGVudCA9ICdSZWJ1aWxkIFJlcG9ydHMnOwogICAgICAgICAgICB9IGVsc2UgewogICAgICAgICAgICAgICAgc2V0U3RhdHVzKHN0YXR1c1JlcERvdCwgc3RhdHVzUmVwVmFsdWUs' +
'ICd3YXJuJywgJ05vbmUgXHUyMDE0IHJ1biBzaW11bGF0aW9ucyBmaXJzdCcpOwogICAgICAgICAgICAgICAgZ2VuUmVwb3J0c0J0bi5zdHlsZS5kaXNwbGF5ID0gJ2lubGluZS1ibG9jayc7CiAgICAgICAgICAgICAgICBnZW5SZXBvcnRzQnRuLnRleHRDb250ZW50ID0gJ0dlbmVyYXRlIFJlcG9ydHMnOwogICAgICAgICAgICB9CiAgICAgICAgfSBjYXRjaCAoZSkgewogICAgICAgICAgICBzZXRTdGF0dXMoc3RhdHVzTGxtRG90LCBzdGF0dXNMbG1WYWx1ZSwgJ2Vycm9yJywgJ1NlcnZpY2UgdW5hdmFpbGFibGUnKTsKICAgICAgICAgICAgc2V0U3RhdHVzKHN0YXR1c0VtYkRvdCwgc3RhdHVzRW1iVmFsdWUsICdlcnJvcicsICdcdTIwMTQnKTsKICAgICAgICAgICAgc2V0U3RhdHVzKHN0YXR1c1JlcERvdCwgc3RhdHVzUmVwVmFsdWUsICdlcnJvcicsICdcdTIwMTQnKTsKICAgICAgICB9CiAgICB9CgogICAgZnVuY3Rpb24gc2V0U3RhdHVzKGRvdEVsLCB2YWx1ZUVsLCBsZXZlbCwgdGV4dCkgewogICAgICAgIGRvdEVsLmNsYXNzTmFtZSA9ICdzdGF0dXMtZG90IHN0YXR1cy0nICsgbGV2ZWw7CiAgICAgICAgdmFsdWVFbC50ZXh0Q29udGVudCA9IHRleHQ7CiAgICB9CgogICAgLy8g4pSA4pSAIExvYWQgRGVja3Mg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgYXN5bmMgZnVuY3Rpb24gbG9hZERlY2tzKCkgewogICAgICAgIHRyeSB7CiAgICAgICAgICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKEFQSSArICcvYXBpL2NvYWNoL2RlY2tzJyk7CiAgICAgICAgICAgIGNvbnN0IGRhdGEgPSBhd2FpdCByZXMuanNvbigpOwogICAgICAgICAgICAvLyBCYWNrZW5kIHJldHVybnMgYSBiYXJlIGFycmF5IG9mIG9iamVjdHMsIG5vdCB7ZGVja3M6IFsuLi5dfQogICAgICAgICAgICBjb25zdCBkZWNrcyA9IEFycmF5LmlzQXJyYXkoZGF0YSkgPyBkYXRhIDogKGRhdGEuZGVja3MgfHwgW10pOwoKICAgICAgICAgICAgd2hpbGUgKGRlY2tTZWxlY3Qub3B0aW9ucy5sZW5ndGggPiAxKSB7CiAgICAgICAgICAgICAgICBkZWNrU2VsZWN0LnJlbW92ZSgxKTsKICAgICAgICAgICAgfQoKICAgICAgICAgICAgaWYgKGRlY2tzLmxlbmd0aCA9PT0gMCkgewogICAgICAgICAgICAgICAgY29uc3Qgb3B0ID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnb3B0aW9uJyk7CiAgICAgICAgICAgICAgICBvcHQuZGlzYWJsZWQgPSB0cnVlOwogICAgICAgICAgICAgICAgb3B0LnRleHRDb250ZW50ID0gJ05vIGRlY2tzIGZvdW5kIFx1MjAxNCBhZGQgZGVja3MgaW4gRGVjayBCdWlsZGVyIGZpcnN0JzsKICAgICAgICAgICAgICAgIGRlY2tTZWxlY3QuYXBwZW5kQ2hpbGQob3B0KTsKICAgICAgICAgICAgICAgIHJldHVybjsKICAgICAgICAgICAgfQoKICAgICAgICAgICAgLy8gU29ydCBieSBkZWNrIG5hbWUKICAgICAgICAgICAgZGVja3Muc29ydCgoYSwgYikgPT4gewogICAgICAgICAgICAgICAgY29uc3QgbmEgPSAodHlwZW9mIGEgPT09ICdzdHJpbmcnID8gYSA6IGEuZGVja19uYW1lIHx8ICcnKS50b0xvd2VyQ2FzZSgpOwogICAgICAgICAgICAgICAgY29uc3QgbmIgPSAodHlwZW9mIGIgPT09ICdzdHJpbmcnID8gYiA6IGIuZGVja19uYW1lIHx8ICcnKS50b0xvd2VyQ2FzZSgpOwogICAgICAgICAgICAgICAgcmV0dXJuIG5hLmxvY2FsZUNvbXBhcmUobmIpOwogICAgICAgICAgICB9KTsKCiAgICAgICAgICAgIGZvciAoY29uc3QgZGVjayBvZiBkZWNrcykgewogICAgICAgICAgICAgICAgY29uc3Qgb3B0ID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnb3B0aW9uJyk7CiAgICAgICAgICAgICAgICBpZiAodHlwZW9mIGRlY2sgPT09ICdzdHJpbmcnKSB7CiAgICAgICAgICAgICAgICAgICAgLy8gTGVnYWN5OiBiYXJlIHN0cmluZyBJRAogICAgICAgICAgICAgICAgICAgIG9wdC52YWx1ZSA9IGRlY2s7CiAgICAgICAgICAgICAgICAgICAgb3B0LnRleHRDb250ZW50ID0gZGVjazsKICAgICAgICAgICAgICAgIH0gZWxzZSB7CiAgICAgICAgICAgICAgICAgICAgLy8gT2JqZWN0IGZvcm1hdDoge2RlY2tfaWQsIGRlY2tfbmFtZSwgY29tbWFuZGVyLCBoYXNfcmVwb3J0LCAuLi59CiAgICAgICAgICAgICAgICAgICAgY29uc3Qgc2x1ZyA9IGRlY2suZGVja19uYW1lLnRvTG93ZXJDYXNlKCkucmVwbGFj' +
'ZSgvW15hLXowLTldKy9nLCAnLScpLnJlcGxhY2UoLyheLXwtJCkvZywgJycpOwogICAgICAgICAgICAgICAgICAgIG9wdC52YWx1ZSA9IHNsdWc7CiAgICAgICAgICAgICAgICAgICAgb3B0LnRleHRDb250ZW50ID0gZGVjay5kZWNrX25hbWUgKyAoZGVjay5jb21tYW5kZXIgPyAnICgnICsgZGVjay5jb21tYW5kZXIgKyAnKScgOiAnJyk7CiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICBkZWNrU2VsZWN0LmFwcGVuZENoaWxkKG9wdCk7CiAgICAgICAgICAgIH0KICAgICAgICB9IGNhdGNoIChlKSB7CiAgICAgICAgICAgIGNvbnNvbGUuZXJyb3IoJ0ZhaWxlZCB0byBsb2FkIGRlY2tzOicsIGUpOwogICAgICAgIH0KICAgIH0KCiAgICAvLyDilIDilIAgTG9hZCBEQiBEZWNrcyAoZm9yIEFwcGx5KSDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKCiAgICBhc3luYyBmdW5jdGlvbiBsb2FkRGJEZWNrcygpIHsKICAgICAgICB0cnkgewogICAgICAgICAgICBjb25zdCByZXMgPSBhd2FpdCBmZXRjaChBUEkgKyAnL2FwaS9kZWNrcycpOwogICAgICAgICAgICBpZiAoIXJlcy5vaykgcmV0dXJuOwogICAgICAgICAgICBjb25zdCBkYXRhID0gYXdhaXQgcmVzLmpzb24oKTsKICAgICAgICAgICAgZGJEZWNrcyA9IGRhdGEuZGVja3MgfHwgW107CgogICAgICAgICAgICB3aGlsZSAoYXBwbHlEZWNrU2VsZWN0Lm9wdGlvbnMubGVuZ3RoID4gMSkgewogICAgICAgICAgICAgICAgYXBwbHlEZWNrU2VsZWN0LnJlbW92ZSgxKTsKICAgICAgICAgICAgfQoKICAgICAgICAgICAgZm9yIChjb25zdCBkZWNrIG9mIGRiRGVja3MpIHsKICAgICAgICAgICAgICAgIGNvbnN0IG9wdCA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoJ29wdGlvbicpOwogICAgICAgICAgICAgICAgb3B0LnZhbHVlID0gZGVjay5pZDsKICAgICAgICAgICAgICAgIG9wdC50ZXh0Q29udGVudCA9IGRlY2submFtZSArIChkZWNrLmNvbW1hbmRlciA/ICcgKCcgKyBkZWNrLmNvbW1hbmRlciArICcpJyA6ICcnKTsKICAgICAgICAgICAgICAgIGFwcGx5RGVja1NlbGVjdC5hcHBlbmRDaGlsZChvcHQpOwogICAgICAgICAgICB9CiAgICAgICAgfSBjYXRjaCAoZSkgewogICAgICAgICAgICBjb25zb2xlLmVycm9yKCdGYWlsZWQgdG8gbG9hZCBEQiBkZWNrczonLCBlKTsKICAgICAgICB9CiAgICB9CgogICAgLy8g4pSA4pSAIExvYWQgUGFzdCBTZXNzaW9ucyDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKCiAgICBhc3luYyBmdW5jdGlvbiBsb2FkU2Vzc2lvbnMoKSB7CiAgICAgICAgdHJ5IHsKICAgICAgICAgICAgY29uc3QgcmVzID0gYXdhaXQgZmV0Y2goQVBJICsgJy9hcGkvY29hY2gvc2Vzc2lvbnMnKTsKICAgICAgICAgICAgY29uc3QgZGF0YSA9IGF3YWl0IHJlcy5qc29uKCk7CiAgICAgICAgICAgIGNvbnN0IHNlc3Npb25zID0gZGF0YS5zZXNzaW9ucyB8fCBbXTsKCiAgICAgICAgICAgIGlmIChzZXNzaW9ucy5sZW5ndGggPT09IDApIHsKICAgICAgICAgICAgICAgIHNlc3Npb25zTGlzdC5pbm5lckhUTUwgPSAnPHAgY2xhc3M9ImNvYWNoLWVtcHR5Ij5ObyBjb2FjaGluZyBzZXNzaW9ucyB5ZXQuIFJ1biB0aGUgY29hY2ggdG8gZ2V0IHN0YXJ0ZWQuPC9wPic7CiAgICAgICAgICAgICAgICByZXR1cm47CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIHNlc3Npb25zTGlzdC5pbm5lckhUTUwgPSAnJzsKICAgICAgICAgICAgZm9yIChjb25zdCBzIG9mIHNlc3Npb25zKSB7CiAgICAgICAgICAgICAgICBjb25zdCBpdGVtID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2Jyk7CiAgICAgICAgICAgICAgICBpdGVtLmNsYXNzTmFtZSA9ICdjb2FjaC1zZXNzaW9uLWl0ZW0nOwogICAgICAgICAgICAgICAgaXRlbS5kYXRhc2V0LnNlc3Npb25JZCA9IHMuc2Vzc2lvbklkOwoKICAgICAgICAgICAgICAgIGNvbnN0IHRpbWVTdHIgPSBzLnRpbWVzdGFtcCA/IGZvcm1hdFRpbWUocy50aW1lc3RhbXApIDogJyc7CiAgICAgICAgICAgICAgICBjb25zdCBpc0V4cGFuZGVkID0gZXhwYW5kZWRTZXNzaW9uc1tzLnNlc3Npb25JZF07CgogICAgICAgICAgICAgICAgaXRlbS5pbm5lckhUTUwgPQogICAgICAgICAgICAgICAgICAgICc8ZGl2IGNsYXNzPSJjb2FjaC1zZXNz' +
'aW9uLWhlYWRlciIgZGF0YS1zaWQ9IicgKyBlc2NIdG1sKHMuc2Vzc2lvbklkKSArICciPicgKwogICAgICAgICAgICAgICAgICAgICAgICAnPHNwYW4gY2xhc3M9ImNvYWNoLXNlc3Npb24tZXhwYW5kLWFycm93Ij4nICsgKGlzRXhwYW5kZWQgPyAnXHUyNUJDJyA6ICdcdTI1QjYnKSArICc8L3NwYW4+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICc8c3BhbiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1kZWNrIj4nICsgZXNjSHRtbChzLmRlY2tJZCkgKyAnPC9zcGFuPicgKwogICAgICAgICAgICAgICAgICAgICAgICAnPHNwYW4gY2xhc3M9ImNvYWNoLXNlc3Npb24tc3VtbWFyeSI+JyArIGVzY0h0bWwocy5zdW1tYXJ5IHx8ICcobm8gc3VtbWFyeSknKSArICc8L3NwYW4+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICc8c3BhbiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1tZXRhIj4nICsKICAgICAgICAgICAgICAgICAgICAgICAgICAgICc8c3BhbiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1jdXRzIj4tJyArIChzLmN1dHNDb3VudCB8fCAwKSArICc8L3NwYW4+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAnPHNwYW4gY2xhc3M9ImNvYWNoLXNlc3Npb24tYWRkcyI+KycgKyAocy5hZGRzQ291bnQgfHwgMCkgKyAnPC9zcGFuPicgKwogICAgICAgICAgICAgICAgICAgICAgICAgICAgJzxzcGFuIGNsYXNzPSJjb2FjaC1zZXNzaW9uLXRpbWUiPicgKyBlc2NIdG1sKHRpbWVTdHIpICsgJzwvc3Bhbj4nICsKICAgICAgICAgICAgICAgICAgICAgICAgJzwvc3Bhbj4nICsKICAgICAgICAgICAgICAgICAgICAnPC9kaXY+JyArCiAgICAgICAgICAgICAgICAgICAgJzxkaXYgY2xhc3M9ImNvYWNoLXNlc3Npb24tZGV0YWlsIiBpZD0ic2Vzc2lvbi1kZXRhaWwtJyArIGVzY0h0bWwocy5zZXNzaW9uSWQpICsgJyIgc3R5bGU9IicgKyAoaXNFeHBhbmRlZCA/ICcnIDogJ2Rpc3BsYXk6bm9uZScpICsgJyI+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICc8ZGl2IGNsYXNzPSJjb2FjaC1zZXNzaW9uLWRldGFpbC1sb2FkaW5nIj5Mb2FkaW5nLi4uPC9kaXY+JyArCiAgICAgICAgICAgICAgICAgICAgJzwvZGl2Pic7CgogICAgICAgICAgICAgICAgLy8gQ2xpY2sgaGFuZGxlciBmb3IgZXhwYW5kL2NvbGxhcHNlCiAgICAgICAgICAgICAgICBpdGVtLnF1ZXJ5U2VsZWN0b3IoJy5jb2FjaC1zZXNzaW9uLWhlYWRlcicpLmFkZEV2ZW50TGlzdGVuZXIoJ2NsaWNrJywgZnVuY3Rpb24gKCkgewogICAgICAgICAgICAgICAgICAgIHRvZ2dsZVNlc3Npb24ocy5zZXNzaW9uSWQpOwogICAgICAgICAgICAgICAgfSk7CgogICAgICAgICAgICAgICAgc2Vzc2lvbnNMaXN0LmFwcGVuZENoaWxkKGl0ZW0pOwoKICAgICAgICAgICAgICAgIC8vIElmIHByZXZpb3VzbHkgZXhwYW5kZWQsIGxvYWQgZGV0YWlsCiAgICAgICAgICAgICAgICBpZiAoaXNFeHBhbmRlZCAmJiBzZXNzaW9uQ2FjaGVbcy5zZXNzaW9uSWRdKSB7CiAgICAgICAgICAgICAgICAgICAgcmVuZGVyU2Vzc2lvbkRldGFpbChzLnNlc3Npb25JZCwgc2Vzc2lvbkNhY2hlW3Muc2Vzc2lvbklkXSk7CiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KICAgICAgICB9IGNhdGNoIChlKSB7CiAgICAgICAgICAgIGNvbnNvbGUuZXJyb3IoJ0ZhaWxlZCB0byBsb2FkIHNlc3Npb25zOicsIGUpOwogICAgICAgIH0KICAgIH0KCiAgICBhc3luYyBmdW5jdGlvbiB0b2dnbGVTZXNzaW9uKHNlc3Npb25JZCkgewogICAgICAgIGNvbnN0IGRldGFpbEVsID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3Nlc3Npb24tZGV0YWlsLScgKyBzZXNzaW9uSWQpOwogICAgICAgIGNvbnN0IGhlYWRlckVsID0gZGV0YWlsRWw/LnByZXZpb3VzRWxlbWVudFNpYmxpbmc7CiAgICAgICAgY29uc3QgYXJyb3dFbCA9IGhlYWRlckVsPy5xdWVyeVNlbGVjdG9yKCcuY29hY2gtc2Vzc2lvbi1leHBhbmQtYXJyb3cnKTsKCiAgICAgICAgaWYgKCFkZXRhaWxFbCkgcmV0dXJuOwoKICAgICAgICBjb25zdCB3YXNIaWRkZW4gPSBkZXRhaWxFbC5zdHlsZS5kaXNwbGF5ID09PSAnbm9uZSc7CgogICAgICAgIGlmICh3YXNIaWRkZW4pIHsKICAgICAgICAgICAgZGV0YWlsRWwuc3R5bGUuZGlzcGxheSA9ICdibG9jayc7CiAgICAgICAgICAgIGV4cGFuZGVkU2Vzc2lvbnNbc2Vzc2lvbklkXSA9IHRydWU7CiAgICAgICAgICAgIGlmIChhcnJvd0VsKSBhcnJvd0VsLnRleHRDb250ZW50ID0gJ1x1MjVCQyc7CgogICAgICAgICAgICBpZiAoIXNlc3Npb25D' +
'YWNoZVtzZXNzaW9uSWRdKSB7CiAgICAgICAgICAgICAgICB0cnkgewogICAgICAgICAgICAgICAgICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKEFQSSArICcvYXBpL2NvYWNoL3Nlc3Npb25zLycgKyBlbmNvZGVVUklDb21wb25lbnQoc2Vzc2lvbklkKSk7CiAgICAgICAgICAgICAgICAgICAgaWYgKCFyZXMub2spIHRocm93IG5ldyBFcnJvcignU2Vzc2lvbiBub3QgZm91bmQnKTsKICAgICAgICAgICAgICAgICAgICBjb25zdCBzZXNzaW9uID0gYXdhaXQgcmVzLmpzb24oKTsKICAgICAgICAgICAgICAgICAgICBzZXNzaW9uQ2FjaGVbc2Vzc2lvbklkXSA9IHNlc3Npb247CiAgICAgICAgICAgICAgICAgICAgcmVuZGVyU2Vzc2lvbkRldGFpbChzZXNzaW9uSWQsIHNlc3Npb24pOwogICAgICAgICAgICAgICAgfSBjYXRjaCAoZSkgewogICAgICAgICAgICAgICAgICAgIGRldGFpbEVsLmlubmVySFRNTCA9ICc8ZGl2IGNsYXNzPSJjb2FjaC1zZXNzaW9uLWRldGFpbC1lcnJvciI+RmFpbGVkIHRvIGxvYWQgc2Vzc2lvbiBkZXRhaWxzPC9kaXY+JzsKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfSBlbHNlIHsKICAgICAgICAgICAgICAgIHJlbmRlclNlc3Npb25EZXRhaWwoc2Vzc2lvbklkLCBzZXNzaW9uQ2FjaGVbc2Vzc2lvbklkXSk7CiAgICAgICAgICAgIH0KICAgICAgICB9IGVsc2UgewogICAgICAgICAgICBkZXRhaWxFbC5zdHlsZS5kaXNwbGF5ID0gJ25vbmUnOwogICAgICAgICAgICBleHBhbmRlZFNlc3Npb25zW3Nlc3Npb25JZF0gPSBmYWxzZTsKICAgICAgICAgICAgaWYgKGFycm93RWwpIGFycm93RWwudGV4dENvbnRlbnQgPSAnXHUyNUI2JzsKICAgICAgICB9CiAgICB9CgogICAgZnVuY3Rpb24gcmVuZGVyU2Vzc2lvbkRldGFpbChzZXNzaW9uSWQsIHNlc3Npb24pIHsKICAgICAgICBjb25zdCBkZXRhaWxFbCA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzZXNzaW9uLWRldGFpbC0nICsgc2Vzc2lvbklkKTsKICAgICAgICBpZiAoIWRldGFpbEVsKSByZXR1cm47CgogICAgICAgIGNvbnN0IGN1dHMgPSBzZXNzaW9uLnN1Z2dlc3RlZEN1dHMgfHwgW107CiAgICAgICAgY29uc3QgYWRkcyA9IHNlc3Npb24uc3VnZ2VzdGVkQWRkcyB8fCBbXTsKCiAgICAgICAgbGV0IGh0bWwgPSAnPGRpdiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1kZXRhaWwtaW5uZXIiPic7CgogICAgICAgIC8vIFN1bW1hcnkKICAgICAgICBpZiAoc2Vzc2lvbi5zdW1tYXJ5KSB7CiAgICAgICAgICAgIGh0bWwgKz0gJzxwIGNsYXNzPSJjb2FjaC1zZXNzaW9uLWRldGFpbC1zdW1tYXJ5Ij4nICsgZXNjSHRtbChzZXNzaW9uLnN1bW1hcnkpICsgJzwvcD4nOwogICAgICAgIH0KCiAgICAgICAgLy8gQ3V0cwogICAgICAgIGlmIChjdXRzLmxlbmd0aCA+IDApIHsKICAgICAgICAgICAgaHRtbCArPSAnPGRpdiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1kZXRhaWwtZ3JvdXAiPic7CiAgICAgICAgICAgIGh0bWwgKz0gJzxzcGFuIGNsYXNzPSJjb2FjaC1zZXNzaW9uLWRldGFpbC1sYWJlbCI+Q3V0czo8L3NwYW4+JzsKICAgICAgICAgICAgaHRtbCArPSAnPGRpdiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1waWxscyI+JzsKICAgICAgICAgICAgZm9yIChjb25zdCBjdXQgb2YgY3V0cykgewogICAgICAgICAgICAgICAgaHRtbCArPSAnPHNwYW4gY2xhc3M9ImNvYWNoLXBpbGwgY29hY2gtcGlsbC1jdXQiIHRpdGxlPSInICsgZXNjSHRtbChjdXQucmVhc29uIHx8ICcnKSArICciPicgKyBlc2NIdG1sKGN1dC5jYXJkTmFtZSkgKyAnPC9zcGFuPic7CiAgICAgICAgICAgIH0KICAgICAgICAgICAgaHRtbCArPSAnPC9kaXY+PC9kaXY+JzsKICAgICAgICB9CgogICAgICAgIC8vIEFkZHMKICAgICAgICBpZiAoYWRkcy5sZW5ndGggPiAwKSB7CiAgICAgICAgICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9ImNvYWNoLXNlc3Npb24tZGV0YWlsLWdyb3VwIj4nOwogICAgICAgICAgICBodG1sICs9ICc8c3BhbiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1kZXRhaWwtbGFiZWwiPkFkZHM6PC9zcGFuPic7CiAgICAgICAgICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9ImNvYWNoLXNlc3Npb24tcGlsbHMiPic7CiAgICAgICAgICAgIGZvciAoY29uc3QgYWRkIG9mIGFkZHMpIHsKICAgICAgICAgICAgICAgIGh0bWwgKz0gJzxzcGFuIGNsYXNzPSJjb2FjaC1waWxsIGNvYWNoLXBpbGwtYWRkIiB0aXRsZT0iJyArIGVzY0h0bWwoYWRkLnJlYXNvbiB8fCAnJykgKyAnIj4nICsgZXNjSHRtbChhZGQuY2FyZE5hbWUp' +
'ICsgJzwvc3Bhbj4nOwogICAgICAgICAgICB9CiAgICAgICAgICAgIGh0bWwgKz0gJzwvZGl2PjwvZGl2Pic7CiAgICAgICAgfQoKICAgICAgICAvLyBMb2FkIGluIENoYXQgYnV0dG9uCiAgICAgICAgaHRtbCArPSAnPGRpdiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1kZXRhaWwtYWN0aW9ucyI+JzsKICAgICAgICBodG1sICs9ICc8YnV0dG9uIGNsYXNzPSJidG4gYnRuLWdob3N0IGJ0bi1zbSBjb2FjaC1sb2FkLWNoYXQtYnRuIiBkYXRhLXNpZD0iJyArIGVzY0h0bWwoc2Vzc2lvbklkKSArICciPkxvYWQgaW4gQ2hhdDwvYnV0dG9uPic7CiAgICAgICAgaHRtbCArPSAnPGJ1dHRvbiBjbGFzcz0iYnRuIGJ0bi1naG9zdCBidG4tc20gY29hY2gtdmlldy1mdWxsLWJ0biIgZGF0YS1zaWQ9IicgKyBlc2NIdG1sKHNlc3Npb25JZCkgKyAnIj5WaWV3IEZ1bGwgUmVzdWx0czwvYnV0dG9uPic7CiAgICAgICAgaHRtbCArPSAnPC9kaXY+JzsKCiAgICAgICAgaHRtbCArPSAnPC9kaXY+JzsKICAgICAgICBkZXRhaWxFbC5pbm5lckhUTUwgPSBodG1sOwoKICAgICAgICAvLyBCaW5kIGJ1dHRvbnMKICAgICAgICBkZXRhaWxFbC5xdWVyeVNlbGVjdG9yKCcuY29hY2gtbG9hZC1jaGF0LWJ0bicpPy5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsIChlKSA9PiB7CiAgICAgICAgICAgIGUuc3RvcFByb3BhZ2F0aW9uKCk7CiAgICAgICAgICAgIGxvYWRTZXNzaW9uSW5DaGF0KHNlc3Npb25JZCk7CiAgICAgICAgfSk7CiAgICAgICAgZGV0YWlsRWwucXVlcnlTZWxlY3RvcignLmNvYWNoLXZpZXctZnVsbC1idG4nKT8uYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCAoZSkgPT4gewogICAgICAgICAgICBlLnN0b3BQcm9wYWdhdGlvbigpOwogICAgICAgICAgICBkaXNwbGF5U2Vzc2lvbihzZXNzaW9uQ2FjaGVbc2Vzc2lvbklkXSk7CiAgICAgICAgICAgIHJlc3VsdHNFbC5zY3JvbGxJbnRvVmlldyh7IGJlaGF2aW9yOiAnc21vb3RoJywgYmxvY2s6ICdzdGFydCcgfSk7CiAgICAgICAgfSk7CiAgICB9CgogICAgZnVuY3Rpb24gbG9hZFNlc3Npb25JbkNoYXQoc2Vzc2lvbklkKSB7CiAgICAgICAgY29uc3Qgc2Vzc2lvbiA9IHNlc3Npb25DYWNoZVtzZXNzaW9uSWRdOwogICAgICAgIGlmICghc2Vzc2lvbikgcmV0dXJuOwoKICAgICAgICBjbGVhckNoYXQoKTsKCiAgICAgICAgLy8gU2V0IGRlY2sgc2VsZWN0b3IgaWYgcG9zc2libGUKICAgICAgICBpZiAoc2Vzc2lvbi5kZWNrSWQpIHsKICAgICAgICAgICAgZm9yIChsZXQgaSA9IDA7IGkgPCBkZWNrU2VsZWN0Lm9wdGlvbnMubGVuZ3RoOyBpKyspIHsKICAgICAgICAgICAgICAgIGlmIChkZWNrU2VsZWN0Lm9wdGlvbnNbaV0udmFsdWUgPT09IHNlc3Npb24uZGVja0lkKSB7CiAgICAgICAgICAgICAgICAgICAgZGVja1NlbGVjdC52YWx1ZSA9IHNlc3Npb24uZGVja0lkOwogICAgICAgICAgICAgICAgICAgIGRlY2tTZWxlY3QuZGlzcGF0Y2hFdmVudChuZXcgRXZlbnQoJ2NoYW5nZScpKTsKICAgICAgICAgICAgICAgICAgICBicmVhazsKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgLy8gUHJlLWZpbGwgY2hhdCB3aXRoIGEgc3VtbWFyeSBjb250ZXh0CiAgICAgICAgY29uc3QgY29udGV4dE1zZyA9ICdCYXNlZCBvbiBhIHByZXZpb3VzIGNvYWNoaW5nIHNlc3Npb24gZm9yIHRoaXMgZGVjaywgaGVyZSB3YXMgdGhlIGFuYWx5c2lzOlxuXG4nICsKICAgICAgICAgICAgKHNlc3Npb24uc3VtbWFyeSB8fCAnJykgKyAnXG5cbicgKwogICAgICAgICAgICAnU3VnZ2VzdGVkIGN1dHM6ICcgKyAoc2Vzc2lvbi5zdWdnZXN0ZWRDdXRzIHx8IFtdKS5tYXAoYyA9PiBjLmNhcmROYW1lKS5qb2luKCcsICcpICsgJ1xuJyArCiAgICAgICAgICAgICdTdWdnZXN0ZWQgYWRkczogJyArIChzZXNzaW9uLnN1Z2dlc3RlZEFkZHMgfHwgW10pLm1hcChhID0+IGEuY2FyZE5hbWUpLmpvaW4oJywgJyk7CgogICAgICAgIGNoYXRIaXN0b3J5LnB1c2goeyByb2xlOiAnYXNzaXN0YW50JywgY29udGVudDogY29udGV4dE1zZyB9KTsKICAgICAgICBhcHBlbmRDaGF0QnViYmxlKCdhc3Npc3RhbnQnLCBjb250ZXh0TXNnKTsKCiAgICAgICAgY2hhdElucHV0LmZvY3VzKCk7CiAgICAgICAgc2hvd1RvYXN0KCdTZXNzaW9uIGxvYWRlZCBpbnRvIGNoYXQnLCAnc3VjY2VzcycpOwogICAgfQoKICAgIC8vIOKUgOKUgCBHZW5lcmF0ZSBSZXBvcnRzIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKU' +
'gOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAoKICAgIGFzeW5jIGZ1bmN0aW9uIGdlbmVyYXRlUmVwb3J0cygpIHsKICAgICAgICBnZW5SZXBvcnRzQnRuLmRpc2FibGVkID0gdHJ1ZTsKICAgICAgICBnZW5SZXBvcnRzQnRuLnRleHRDb250ZW50ID0gJ0dlbmVyYXRpbmcuLi4nOwoKICAgICAgICB0cnkgewogICAgICAgICAgICBjb25zdCByZXMgPSBhd2FpdCBmZXRjaChBUEkgKyAnL2FwaS9jb2FjaC9yZXBvcnRzL2dlbmVyYXRlJywgeyBtZXRob2Q6ICdQT1NUJyB9KTsKICAgICAgICAgICAgY29uc3QgZGF0YSA9IGF3YWl0IHJlcy5qc29uKCk7CgogICAgICAgICAgICBpZiAoZGF0YS5jb3VudCA+IDApIHsKICAgICAgICAgICAgICAgIGdlblJlcG9ydHNCdG4udGV4dENvbnRlbnQgPSBkYXRhLmNvdW50ICsgJyByZXBvcnRzIGdlbmVyYXRlZCc7CiAgICAgICAgICAgICAgICBhd2FpdCBjaGVja1N0YXR1cygpOwogICAgICAgICAgICAgICAgYXdhaXQgbG9hZERlY2tzKCk7CiAgICAgICAgICAgIH0gZWxzZSB7CiAgICAgICAgICAgICAgICBnZW5SZXBvcnRzQnRuLnRleHRDb250ZW50ID0gJ05vIHJlc3VsdHMgZm91bmQnOwogICAgICAgICAgICB9CiAgICAgICAgfSBjYXRjaCAoZSkgewogICAgICAgICAgICBnZW5SZXBvcnRzQnRuLnRleHRDb250ZW50ID0gJ0Vycm9yOiAnICsgZS5tZXNzYWdlOwogICAgICAgIH0gZmluYWxseSB7CiAgICAgICAgICAgIHNldFRpbWVvdXQoKCkgPT4gewogICAgICAgICAgICAgICAgZ2VuUmVwb3J0c0J0bi5kaXNhYmxlZCA9IGZhbHNlOwogICAgICAgICAgICAgICAgZ2VuUmVwb3J0c0J0bi50ZXh0Q29udGVudCA9ICdSZWJ1aWxkIFJlcG9ydHMnOwogICAgICAgICAgICB9LCAzMDAwKTsKICAgICAgICB9CiAgICB9CgogICAgLy8g4pSA4pSAIFJ1biBDb2FjaCAob25lLXNob3QpIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAoKICAgIGFzeW5jIGZ1bmN0aW9uIHJ1bkNvYWNoKCkgewogICAgICAgIGNvbnN0IGRlY2tJZCA9IGRlY2tTZWxlY3QudmFsdWU7CiAgICAgICAgaWYgKCFkZWNrSWQpIHJldHVybjsKCiAgICAgICAgY29uc3QgZ29hbHMgPSBnZXRHb2FscygpOwoKICAgICAgICBydW5CdG4uZGlzYWJsZWQgPSB0cnVlOwogICAgICAgIHByb2dyZXNzRWwuc3R5bGUuZGlzcGxheSA9ICdmbGV4JzsKICAgICAgICBwcm9ncmVzc1RleHQudGV4dENvbnRlbnQgPSAnQW5hbHl6aW5nIGRlY2sgYW5kIGNvbnN1bHRpbmcgTExNLi4uIFRoaXMgbWF5IHRha2UgMzAtNjAgc2Vjb25kcy4nOwogICAgICAgIHJlc3VsdHNFbC5zdHlsZS5kaXNwbGF5ID0gJ25vbmUnOwoKICAgICAgICBjb25zdCBwcmV2RXJyb3IgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCcuY29hY2gtZXJyb3InKTsKICAgICAgICBpZiAocHJldkVycm9yKSBwcmV2RXJyb3IucmVtb3ZlKCk7CgogICAgICAgIHRyeSB7CiAgICAgICAgICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKEFQSSArICcvYXBpL2NvYWNoL2RlY2tzLycgKyBlbmNvZGVVUklDb21wb25lbnQoZGVja0lkKSwgewogICAgICAgICAgICAgICAgbWV0aG9kOiAnUE9TVCcsCiAgICAgICAgICAgICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSwKICAgICAgICAgICAgICAgIGJvZHk6IEpTT04uc3RyaW5naWZ5KHsgZ29hbHM6IGdvYWxzIH0pLAogICAgICAgICAgICB9KTsKCiAgICAgICAgICAgIGlmICghcmVzLm9rKSB7CiAgICAgICAgICAgICAgICBjb25zdCBlcnIgPSBhd2FpdCByZXMuanNvbigpLmNhdGNoKCgpID0+ICh7IGRldGFpbDogJ1Vua25vd24gZXJyb3InIH0pKTsKICAgICAgICAgICAgICAgIHRocm93IG5ldyBFcnJvcihlcnIuZGV0YWlsIHx8ICdDb2FjaCByZXF1ZXN0IGZhaWxlZCAoJyArIHJlcy5zdGF0dXMgKyAnKScpOwogICAgICAgICAgICB9CgogICAgICAgICAgICBjb25zdCBzZXNzaW9uID0gYXdhaXQgcmVzLmpzb24oKTsKICAgICAgICAgICAgY3VycmVudFNlc3Npb25JZCA9IHNlc3Npb24uc2Vzc2lvbklkIHx8IG51bGw7CiAgICAgICAgICAgIGRpc3BsYXlTZXNzaW9uKHNlc3Npb24pOwogICAgICAgICAgICBsb2FkU2Vzc2lvbnMoKTsKICAgICAgICB9IGNhdGNoIChlKSB7CiAgICAgICAgICAgIGNvbnN0IGVyckRpdiA9IGRvY3VtZW50' +
'LmNyZWF0ZUVsZW1lbnQoJ2RpdicpOwogICAgICAgICAgICBlcnJEaXYuY2xhc3NOYW1lID0gJ2NvYWNoLWVycm9yJzsKICAgICAgICAgICAgZXJyRGl2LnRleHRDb250ZW50ID0gJ0NvYWNoIGVycm9yOiAnICsgZS5tZXNzYWdlOwogICAgICAgICAgICBkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCcuY29hY2gtY29uZmlnJykuYXBwZW5kQ2hpbGQoZXJyRGl2KTsKICAgICAgICB9IGZpbmFsbHkgewogICAgICAgICAgICBydW5CdG4uZGlzYWJsZWQgPSBmYWxzZTsKICAgICAgICAgICAgcHJvZ3Jlc3NFbC5zdHlsZS5kaXNwbGF5ID0gJ25vbmUnOwogICAgICAgIH0KICAgIH0KCiAgICAvLyDilIDilIAgRGlzcGxheSBTZXNzaW9uIFJlc3VsdHMg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgZnVuY3Rpb24gZGlzcGxheVNlc3Npb24oc2Vzc2lvbikgewogICAgICAgIHJlc3VsdHNFbC5zdHlsZS5kaXNwbGF5ID0gJ2ZsZXgnOwogICAgICAgIGN1cnJlbnRTZXNzaW9uSWQgPSBzZXNzaW9uLnNlc3Npb25JZCB8fCBjdXJyZW50U2Vzc2lvbklkOwoKICAgICAgICBzdW1tYXJ5VGV4dC50ZXh0Q29udGVudCA9IHNlc3Npb24uc3VtbWFyeSB8fCAnKE5vIHN1bW1hcnkgcHJvdmlkZWQpJzsKCiAgICAgICAgbWV0YVJvdy5pbm5lckhUTUwgPSAnJzsKICAgICAgICBpZiAoc2Vzc2lvbi5tb2RlbFVzZWQpIGFkZE1ldGFDaGlwKCdNb2RlbCcsIHNlc3Npb24ubW9kZWxVc2VkKTsKICAgICAgICBpZiAoc2Vzc2lvbi5wcm9tcHRUb2tlbnMpIGFkZE1ldGFDaGlwKCdQcm9tcHQnLCBzZXNzaW9uLnByb21wdFRva2Vucy50b0xvY2FsZVN0cmluZygpICsgJyB0b2tlbnMnKTsKICAgICAgICBpZiAoc2Vzc2lvbi5jb21wbGV0aW9uVG9rZW5zKSBhZGRNZXRhQ2hpcCgnQ29tcGxldGlvbicsIHNlc3Npb24uY29tcGxldGlvblRva2Vucy50b0xvY2FsZVN0cmluZygpICsgJyB0b2tlbnMnKTsKICAgICAgICBpZiAoc2Vzc2lvbi5kZWNrSWQpIGFkZE1ldGFDaGlwKCdEZWNrJywgc2Vzc2lvbi5kZWNrSWQpOwogICAgICAgIGlmIChzZXNzaW9uLnRpbWVzdGFtcCkgYWRkTWV0YUNoaXAoJ1RpbWUnLCBmb3JtYXRUaW1lKHNlc3Npb24udGltZXN0YW1wKSk7CgogICAgICAgIC8vIEN1dHMgdGFibGUKICAgICAgICBjb25zdCBjdXRzID0gc2Vzc2lvbi5zdWdnZXN0ZWRDdXRzIHx8IFtdOwogICAgICAgIGN1dHNDb3VudC50ZXh0Q29udGVudCA9IGN1dHMubGVuZ3RoOwogICAgICAgIGN1dHNCb2R5LmlubmVySFRNTCA9ICcnOwogICAgICAgIGN1dHNBbGxDaGVjay5jaGVja2VkID0gZmFsc2U7CgogICAgICAgIGZvciAoY29uc3QgY3V0IG9mIGN1dHMpIHsKICAgICAgICAgICAgY29uc3QgdHIgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCd0cicpOwogICAgICAgICAgICB0ci5pbm5lckhUTUwgPQogICAgICAgICAgICAgICAgJzx0ZD48aW5wdXQgdHlwZT0iY2hlY2tib3giIGRhdGEtY2FyZD0iJyArIGVzY0h0bWwoY3V0LmNhcmROYW1lKSArICciIC8+PC90ZD4nICsKICAgICAgICAgICAgICAgICc8dGQ+PHNwYW4gY2xhc3M9ImNvYWNoLWNhcmQtbmFtZSI+JyArIGVzY0h0bWwoY3V0LmNhcmROYW1lKSArICc8L3NwYW4+PC90ZD4nICsKICAgICAgICAgICAgICAgICc8dGQ+JyArIGVzY0h0bWwoY3V0LnJlYXNvbikgKyAnPC90ZD4nICsKICAgICAgICAgICAgICAgICc8dGQ+PGRpdiBjbGFzcz0iY29hY2gtcmVwbGFjZW1lbnRzIj4nICsKICAgICAgICAgICAgICAgICAgICAoY3V0LnJlcGxhY2VtZW50T3B0aW9ucyB8fCBbXSkubWFwKHIgPT4KICAgICAgICAgICAgICAgICAgICAgICAgJzxzcGFuIGNsYXNzPSJjb2FjaC1yZXBsYWNlbWVudC1jaGlwIj4nICsgZXNjSHRtbChyKSArICc8L3NwYW4+JwogICAgICAgICAgICAgICAgICAgICkuam9pbignJykgKwogICAgICAgICAgICAgICAgJzwvZGl2PjwvdGQ+JyArCiAgICAgICAgICAgICAgICAnPHRkPicgKyBmb3JtYXRJbXBhY3QoY3V0LmN1cnJlbnRJbXBhY3RTY29yZSkgKyAnPC90ZD4nOwogICAgICAgICAgICBjdXRzQm9keS5hcHBlbmRDaGlsZCh0cik7CiAgICAgICAgfQoKICAgICAgICAvLyBBZGRzIHRhYmxlCiAgICAgICAgY29uc3QgYWRkcyA9IHNlc3Npb24uc3VnZ2VzdGVkQWRkcyB8fCBbXTsKICAgICAgICBhZGRzQ291bnQudGV4dENvbnRlbnQgPSBhZGRzLmxlbmd0aDsKICAgICAgICBhZGRzQm9keS5pbm5lckhU' +
'TUwgPSAnJzsKICAgICAgICBhZGRzQWxsQ2hlY2suY2hlY2tlZCA9IGZhbHNlOwoKICAgICAgICBmb3IgKGNvbnN0IGFkZCBvZiBhZGRzKSB7CiAgICAgICAgICAgIGNvbnN0IHRyID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgndHInKTsKICAgICAgICAgICAgdHIuaW5uZXJIVE1MID0KICAgICAgICAgICAgICAgICc8dGQ+PGlucHV0IHR5cGU9ImNoZWNrYm94IiBkYXRhLWNhcmQ9IicgKyBlc2NIdG1sKGFkZC5jYXJkTmFtZSkgKyAnIiAvPjwvdGQ+JyArCiAgICAgICAgICAgICAgICAnPHRkPjxzcGFuIGNsYXNzPSJjb2FjaC1jYXJkLW5hbWUiPicgKyBlc2NIdG1sKGFkZC5jYXJkTmFtZSkgKyAnPC9zcGFuPjwvdGQ+JyArCiAgICAgICAgICAgICAgICAnPHRkPicgKyAoYWRkLnJvbGUgPyAnPHNwYW4gY2xhc3M9ImNvYWNoLXJvbGUtYmFkZ2UiPicgKyBlc2NIdG1sKGFkZC5yb2xlKSArICc8L3NwYW4+JyA6ICcnKSArICc8L3RkPicgKwogICAgICAgICAgICAgICAgJzx0ZD4nICsgZXNjSHRtbChhZGQucmVhc29uKSArICc8L3RkPicgKwogICAgICAgICAgICAgICAgJzx0ZD48ZGl2IGNsYXNzPSJjb2FjaC1yZXBsYWNlbWVudHMiPicgKwogICAgICAgICAgICAgICAgICAgIChhZGQuc3luZXJneVdpdGggfHwgW10pLm1hcChzID0+CiAgICAgICAgICAgICAgICAgICAgICAgICc8c3BhbiBjbGFzcz0iY29hY2gtc3luZXJneS1jaGlwIj4nICsgZXNjSHRtbChzKSArICc8L3NwYW4+JwogICAgICAgICAgICAgICAgICAgICkuam9pbignJykgKwogICAgICAgICAgICAgICAgJzwvZGl2PjwvdGQ+JzsKICAgICAgICAgICAgYWRkc0JvZHkuYXBwZW5kQ2hpbGQodHIpOwogICAgICAgIH0KCiAgICAgICAgLy8gSGludHMKICAgICAgICBjb25zdCBoaW50cyA9IHNlc3Npb24uaGV1cmlzdGljSGludHMgfHwgW107CiAgICAgICAgaWYgKGhpbnRzLmxlbmd0aCA+IDApIHsKICAgICAgICAgICAgaGludHNDYXJkLnN0eWxlLmRpc3BsYXkgPSAnYmxvY2snOwogICAgICAgICAgICBoaW50c0xpc3QuaW5uZXJIVE1MID0gJyc7CiAgICAgICAgICAgIGZvciAoY29uc3QgaGludCBvZiBoaW50cykgewogICAgICAgICAgICAgICAgY29uc3QgbGkgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCdsaScpOwogICAgICAgICAgICAgICAgbGkudGV4dENvbnRlbnQgPSBoaW50OwogICAgICAgICAgICAgICAgaGludHNMaXN0LmFwcGVuZENoaWxkKGxpKTsKICAgICAgICAgICAgfQogICAgICAgIH0gZWxzZSB7CiAgICAgICAgICAgIGhpbnRzQ2FyZC5zdHlsZS5kaXNwbGF5ID0gJ25vbmUnOwogICAgICAgIH0KCiAgICAgICAgLy8gTWFuYSBhZHZpY2UKICAgICAgICBpZiAoc2Vzc2lvbi5tYW5hQmFzZUFkdmljZSkgewogICAgICAgICAgICBtYW5hQ2FyZC5zdHlsZS5kaXNwbGF5ID0gJ2Jsb2NrJzsKICAgICAgICAgICAgbWFuYVRleHQudGV4dENvbnRlbnQgPSBzZXNzaW9uLm1hbmFCYXNlQWR2aWNlOwogICAgICAgIH0gZWxzZSB7CiAgICAgICAgICAgIG1hbmFDYXJkLnN0eWxlLmRpc3BsYXkgPSAnbm9uZSc7CiAgICAgICAgfQoKICAgICAgICAvLyBSYXcgdGV4dAogICAgICAgIHJhd1RleHQudGV4dENvbnRlbnQgPSBzZXNzaW9uLnJhd1RleHRFeHBsYW5hdGlvbiB8fCBzZXNzaW9uLnN1bW1hcnkgfHwgJyhlbXB0eSknOwoKICAgICAgICB1cGRhdGVBcHBseUNvdW50KCk7CiAgICB9CgogICAgLy8g4pSA4pSAIEFwcGx5IFN1Z2dlc3Rpb25zIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAoKICAgIGZ1bmN0aW9uIHVwZGF0ZUFwcGx5Q291bnQoKSB7CiAgICAgICAgY29uc3QgY3V0Q2hlY2tlZCA9IGN1dHNCb2R5LnF1ZXJ5U2VsZWN0b3JBbGwoJ2lucHV0W3R5cGU9ImNoZWNrYm94Il06Y2hlY2tlZCcpOwogICAgICAgIGNvbnN0IGFkZENoZWNrZWQgPSBhZGRzQm9keS5xdWVyeVNlbGVjdG9yQWxsKCdpbnB1dFt0eXBlPSJjaGVja2JveCJdOmNoZWNrZWQnKTsKICAgICAgICBjb25zdCB0b3RhbCA9IGN1dENoZWNrZWQubGVuZ3RoICsgYWRkQ2hlY2tlZC5sZW5ndGg7CgogICAgICAgIGFwcGx5Q291bnRFbC50ZXh0Q29udGVudCA9IGN1dENoZWNrZWQubGVuZ3RoICsgJyBjdXRzLCAnICsgYWRkQ2hlY2tlZC5sZW5ndGggKyAnIGFkZHMgc2VsZWN0ZWQnOwogICAgICAgIGFwcGx5QnRuLmRpc2FibGVkID0gdG90YWwgPT09IDAgfHwgIWFwcGx5RGVja1Nl' +
'bGVjdC52YWx1ZTsKICAgIH0KCiAgICBhcHBseURlY2tTZWxlY3QuYWRkRXZlbnRMaXN0ZW5lcignY2hhbmdlJywgdXBkYXRlQXBwbHlDb3VudCk7CgogICAgYXN5bmMgZnVuY3Rpb24gYXBwbHlTdWdnZXN0aW9ucygpIHsKICAgICAgICBjb25zdCBkZWNrSWQgPSBwYXJzZUludChhcHBseURlY2tTZWxlY3QudmFsdWUpOwogICAgICAgIGlmICghZGVja0lkKSB7CiAgICAgICAgICAgIHNob3dUb2FzdCgnU2VsZWN0IGEgZGVjayB0byBhcHBseSBzdWdnZXN0aW9ucyB0bycsICd3YXJuaW5nJyk7CiAgICAgICAgICAgIHJldHVybjsKICAgICAgICB9CgogICAgICAgIGNvbnN0IGFjY2VwdGVkQ3V0cyA9IFtdOwogICAgICAgIGN1dHNCb2R5LnF1ZXJ5U2VsZWN0b3JBbGwoJ2lucHV0W3R5cGU9ImNoZWNrYm94Il06Y2hlY2tlZCcpLmZvckVhY2goY2IgPT4gewogICAgICAgICAgICBjb25zdCBuYW1lID0gY2IuZGF0YXNldC5jYXJkOwogICAgICAgICAgICBpZiAobmFtZSkgYWNjZXB0ZWRDdXRzLnB1c2gobmFtZSk7CiAgICAgICAgfSk7CgogICAgICAgIGNvbnN0IGFjY2VwdGVkQWRkcyA9IFtdOwogICAgICAgIGFkZHNCb2R5LnF1ZXJ5U2VsZWN0b3JBbGwoJ2lucHV0W3R5cGU9ImNoZWNrYm94Il06Y2hlY2tlZCcpLmZvckVhY2goY2IgPT4gewogICAgICAgICAgICBjb25zdCBuYW1lID0gY2IuZGF0YXNldC5jYXJkOwogICAgICAgICAgICBpZiAobmFtZSkgYWNjZXB0ZWRBZGRzLnB1c2gobmFtZSk7CiAgICAgICAgfSk7CgogICAgICAgIGlmIChhY2NlcHRlZEN1dHMubGVuZ3RoID09PSAwICYmIGFjY2VwdGVkQWRkcy5sZW5ndGggPT09IDApIHsKICAgICAgICAgICAgc2hvd1RvYXN0KCdObyBzdWdnZXN0aW9ucyBzZWxlY3RlZCcsICd3YXJuaW5nJyk7CiAgICAgICAgICAgIHJldHVybjsKICAgICAgICB9CgogICAgICAgIGFwcGx5QnRuLmRpc2FibGVkID0gdHJ1ZTsKICAgICAgICBhcHBseUJ0bi50ZXh0Q29udGVudCA9ICdBcHBseWluZy4uLic7CgogICAgICAgIHRyeSB7CiAgICAgICAgICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKEFQSSArICcvYXBpL2NvYWNoL2FwcGx5JywgewogICAgICAgICAgICAgICAgbWV0aG9kOiAnUE9TVCcsCiAgICAgICAgICAgICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSwKICAgICAgICAgICAgICAgIGJvZHk6IEpTT04uc3RyaW5naWZ5KHsKICAgICAgICAgICAgICAgICAgICBzZXNzaW9uX2lkOiBjdXJyZW50U2Vzc2lvbklkIHx8ICdtYW51YWwnLAogICAgICAgICAgICAgICAgICAgIGRlY2tfaWQ6IGRlY2tJZCwKICAgICAgICAgICAgICAgICAgICBhY2NlcHRlZF9jdXRzOiBhY2NlcHRlZEN1dHMsCiAgICAgICAgICAgICAgICAgICAgYWNjZXB0ZWRfYWRkczogYWNjZXB0ZWRBZGRzLAogICAgICAgICAgICAgICAgfSksCiAgICAgICAgICAgIH0pOwoKICAgICAgICAgICAgaWYgKCFyZXMub2spIHsKICAgICAgICAgICAgICAgIGNvbnN0IGVyciA9IGF3YWl0IHJlcy5qc29uKCkuY2F0Y2goKCkgPT4gKHsgZGV0YWlsOiAnQXBwbHkgZmFpbGVkJyB9KSk7CiAgICAgICAgICAgICAgICB0aHJvdyBuZXcgRXJyb3IoZXJyLmRldGFpbCB8fCAnQXBwbHkgZmFpbGVkJyk7CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGNvbnN0IHJlc3VsdCA9IGF3YWl0IHJlcy5qc29uKCk7CiAgICAgICAgICAgIGNvbnN0IG1zZyA9ICdBcHBsaWVkOiAnICsgcmVzdWx0LnRvdGFsX2N1dHMgKyAnIGN1dHMsICcgKyByZXN1bHQudG90YWxfYWRkcyArICcgYWRkcycgKwogICAgICAgICAgICAgICAgKHJlc3VsdC5lcnJvcnMubGVuZ3RoID4gMCA/ICcgKCcgKyByZXN1bHQuZXJyb3JzLmxlbmd0aCArICcgZXJyb3JzKScgOiAnJyk7CiAgICAgICAgICAgIHNob3dUb2FzdChtc2csIHJlc3VsdC5lcnJvcnMubGVuZ3RoID4gMCA/ICd3YXJuaW5nJyA6ICdzdWNjZXNzJyk7CgogICAgICAgIH0gY2F0Y2ggKGUpIHsKICAgICAgICAgICAgc2hvd1RvYXN0KCdBcHBseSBlcnJvcjogJyArIGUubWVzc2FnZSwgJ2Vycm9yJyk7CiAgICAgICAgfSBmaW5hbGx5IHsKICAgICAgICAgICAgYXBwbHlCdG4uZGlzYWJsZWQgPSBmYWxzZTsKICAgICAgICAgICAgYXBwbHlCdG4udGV4dENvbnRlbnQgPSAnQXBwbHkgU3VnZ2VzdGlvbnMnOwogICAgICAgICAgICB1cGRhdGVBcHBseUNvdW50KCk7CiAgICAgICAgfQogICAgfQoKICAgIC8vIOKUgOKUgCBNdWx0aS10dXJuIENoYXQg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA' +
'4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgYXN5bmMgZnVuY3Rpb24gc2VuZENoYXRNZXNzYWdlKCkgewogICAgICAgIGNvbnN0IHRleHQgPSBjaGF0SW5wdXQudmFsdWUudHJpbSgpOwogICAgICAgIGlmICghdGV4dCB8fCAhZGVja1NlbGVjdC52YWx1ZSB8fCBpc1N0cmVhbWluZykgcmV0dXJuOwoKICAgICAgICAvLyBBZGQgdXNlciBtZXNzYWdlCiAgICAgICAgY2hhdEhpc3RvcnkucHVzaCh7IHJvbGU6ICd1c2VyJywgY29udGVudDogdGV4dCB9KTsKICAgICAgICBhcHBlbmRDaGF0QnViYmxlKCd1c2VyJywgdGV4dCk7CiAgICAgICAgY2hhdElucHV0LnZhbHVlID0gJyc7CiAgICAgICAgY2hhdFNlbmRCdG4uZGlzYWJsZWQgPSB0cnVlOwoKICAgICAgICAvLyBTaG93IHR5cGluZyBpbmRpY2F0b3IKICAgICAgICBjb25zdCB0eXBpbmdFbCA9IHNob3dUeXBpbmdJbmRpY2F0b3IoKTsKICAgICAgICBpc1N0cmVhbWluZyA9IHRydWU7CgogICAgICAgIHRyeSB7CiAgICAgICAgICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKEFQSSArICcvYXBpL2NvYWNoL2NoYXQnLCB7CiAgICAgICAgICAgICAgICBtZXRob2Q6ICdQT1NUJywKICAgICAgICAgICAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LAogICAgICAgICAgICAgICAgYm9keTogSlNPTi5zdHJpbmdpZnkoewogICAgICAgICAgICAgICAgICAgIGRlY2tfaWQ6IGRlY2tTZWxlY3QudmFsdWUsCiAgICAgICAgICAgICAgICAgICAgbWVzc2FnZXM6IGNoYXRIaXN0b3J5LAogICAgICAgICAgICAgICAgICAgIGdvYWxzOiBnZXRHb2FscygpLAogICAgICAgICAgICAgICAgICAgIHN0cmVhbTogdHJ1ZSwKICAgICAgICAgICAgICAgIH0pLAogICAgICAgICAgICB9KTsKCiAgICAgICAgICAgIGlmICghcmVzLm9rKSB7CiAgICAgICAgICAgICAgICBjb25zdCBlcnIgPSBhd2FpdCByZXMuanNvbigpLmNhdGNoKCgpID0+ICh7IGRldGFpbDogJ0NoYXQgZmFpbGVkJyB9KSk7CiAgICAgICAgICAgICAgICB0aHJvdyBuZXcgRXJyb3IoZXJyLmRldGFpbCB8fCAnQ2hhdCBmYWlsZWQgKCcgKyByZXMuc3RhdHVzICsgJyknKTsKICAgICAgICAgICAgfQoKICAgICAgICAgICAgLy8gUmVtb3ZlIHR5cGluZyBpbmRpY2F0b3IsIGFkZCBhc3Npc3RhbnQgYnViYmxlCiAgICAgICAgICAgIHR5cGluZ0VsLnJlbW92ZSgpOwogICAgICAgICAgICBjb25zdCBidWJibGVFbCA9IGFwcGVuZENoYXRCdWJibGUoJ2Fzc2lzdGFudCcsICcnKTsKICAgICAgICAgICAgY29uc3QgY29udGVudEVsID0gYnViYmxlRWwucXVlcnlTZWxlY3RvcignLmNvYWNoLWNoYXQtYnViYmxlLWNvbnRlbnQnKTsKCiAgICAgICAgICAgIGxldCBmdWxsQ29udGVudCA9ICcnOwoKICAgICAgICAgICAgLy8gUmVhZCBTU0Ugc3RyZWFtCiAgICAgICAgICAgIGNvbnN0IHJlYWRlciA9IHJlcy5ib2R5LmdldFJlYWRlcigpOwogICAgICAgICAgICBjb25zdCBkZWNvZGVyID0gbmV3IFRleHREZWNvZGVyKCk7CiAgICAgICAgICAgIGxldCBidWZmZXIgPSAnJzsKCiAgICAgICAgICAgIHdoaWxlICh0cnVlKSB7CiAgICAgICAgICAgICAgICBjb25zdCB7IGRvbmUsIHZhbHVlIH0gPSBhd2FpdCByZWFkZXIucmVhZCgpOwogICAgICAgICAgICAgICAgaWYgKGRvbmUpIGJyZWFrOwoKICAgICAgICAgICAgICAgIGJ1ZmZlciArPSBkZWNvZGVyLmRlY29kZSh2YWx1ZSwgeyBzdHJlYW06IHRydWUgfSk7CiAgICAgICAgICAgICAgICBjb25zdCBsaW5lcyA9IGJ1ZmZlci5zcGxpdCgnXG4nKTsKICAgICAgICAgICAgICAgIGJ1ZmZlciA9IGxpbmVzLnBvcCgpIHx8ICcnOwoKICAgICAgICAgICAgICAgIGZvciAoY29uc3QgbGluZSBvZiBsaW5lcykgewogICAgICAgICAgICAgICAgICAgIGNvbnN0IHRyaW1tZWQgPSBsaW5lLnRyaW0oKTsKICAgICAgICAgICAgICAgICAgICBpZiAoIXRyaW1tZWQuc3RhcnRzV2l0aCgnZGF0YTogJykpIGNvbnRpbnVlOwoKICAgICAgICAgICAgICAgICAgICBjb25zdCBwYXlsb2FkID0gdHJpbW1lZC5zbGljZSg2KTsKICAgICAgICAgICAgICAgICAgICBpZiAocGF5bG9hZCA9PT0gJ1tET05FXScpIGJyZWFrOwoKICAgICAgICAgICAgICAgICAgICB0cnkgewogICAgICAgICAgICAgICAgICAgICAgICBjb25zdCBjaHVuayA9IEpTT04ucGFyc2UocGF5bG9hZCk7CiAgICAgICAgICAgICAg' +
'ICAgICAgICAgIGlmIChjaHVuay5jb250ZW50KSB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICBmdWxsQ29udGVudCArPSBjaHVuay5jb250ZW50OwogICAgICAgICAgICAgICAgICAgICAgICAgICAgY29udGVudEVsLmlubmVySFRNTCA9IGZvcm1hdE1hcmtkb3duKGZ1bGxDb250ZW50KTsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIHNjcm9sbENoYXRUb0JvdHRvbSgpOwogICAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgfSBjYXRjaCAoZSkgewogICAgICAgICAgICAgICAgICAgICAgICAvLyBza2lwIG1hbGZvcm1lZCBjaHVua3MKICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIC8vIEZpbmFsaXplCiAgICAgICAgICAgIGNoYXRIaXN0b3J5LnB1c2goeyByb2xlOiAnYXNzaXN0YW50JywgY29udGVudDogZnVsbENvbnRlbnQgfSk7CiAgICAgICAgICAgIGNvbnRlbnRFbC5pbm5lckhUTUwgPSBmb3JtYXRNYXJrZG93bihmdWxsQ29udGVudCk7CiAgICAgICAgICAgIHNjcm9sbENoYXRUb0JvdHRvbSgpOwoKICAgICAgICB9IGNhdGNoIChlKSB7CiAgICAgICAgICAgIHR5cGluZ0VsLnJlbW92ZSgpOwogICAgICAgICAgICBhcHBlbmRDaGF0QnViYmxlKCdzeXN0ZW0nLCAnRXJyb3I6ICcgKyBlLm1lc3NhZ2UpOwogICAgICAgIH0gZmluYWxseSB7CiAgICAgICAgICAgIGlzU3RyZWFtaW5nID0gZmFsc2U7CiAgICAgICAgICAgIGNoYXRTZW5kQnRuLmRpc2FibGVkID0gIWNoYXRJbnB1dC52YWx1ZS50cmltKCkgfHwgIWRlY2tTZWxlY3QudmFsdWU7CiAgICAgICAgfQogICAgfQoKICAgIGZ1bmN0aW9uIGFwcGVuZENoYXRCdWJibGUocm9sZSwgY29udGVudCkgewogICAgICAgIC8vIFJlbW92ZSB3ZWxjb21lIG1lc3NhZ2UKICAgICAgICBjb25zdCB3ZWxjb21lID0gY2hhdE1lc3NhZ2VzLnF1ZXJ5U2VsZWN0b3IoJy5jb2FjaC1jaGF0LXdlbGNvbWUnKTsKICAgICAgICBpZiAod2VsY29tZSkgd2VsY29tZS5yZW1vdmUoKTsKCiAgICAgICAgY29uc3QgYnViYmxlID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2Jyk7CiAgICAgICAgYnViYmxlLmNsYXNzTmFtZSA9ICdjb2FjaC1jaGF0LWJ1YmJsZSBjb2FjaC1jaGF0LScgKyByb2xlOwoKICAgICAgICBjb25zdCBsYWJlbCA9IHJvbGUgPT09ICd1c2VyJyA/ICdZb3UnIDogcm9sZSA9PT0gJ2Fzc2lzdGFudCcgPyAnQ29hY2gnIDogJ1N5c3RlbSc7CiAgICAgICAgYnViYmxlLmlubmVySFRNTCA9CiAgICAgICAgICAgICc8ZGl2IGNsYXNzPSJjb2FjaC1jaGF0LWJ1YmJsZS1sYWJlbCI+JyArIGxhYmVsICsgJzwvZGl2PicgKwogICAgICAgICAgICAnPGRpdiBjbGFzcz0iY29hY2gtY2hhdC1idWJibGUtY29udGVudCI+JyArIChjb250ZW50ID8gZm9ybWF0TWFya2Rvd24oY29udGVudCkgOiAnJykgKyAnPC9kaXY+JzsKCiAgICAgICAgY2hhdE1lc3NhZ2VzLmFwcGVuZENoaWxkKGJ1YmJsZSk7CiAgICAgICAgc2Nyb2xsQ2hhdFRvQm90dG9tKCk7CiAgICAgICAgcmV0dXJuIGJ1YmJsZTsKICAgIH0KCiAgICBmdW5jdGlvbiBzaG93VHlwaW5nSW5kaWNhdG9yKCkgewogICAgICAgIGNvbnN0IGVsID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2Jyk7CiAgICAgICAgZWwuY2xhc3NOYW1lID0gJ2NvYWNoLWNoYXQtdHlwaW5nJzsKICAgICAgICBlbC5pbm5lckhUTUwgPQogICAgICAgICAgICAnPGRpdiBjbGFzcz0iY29hY2gtY2hhdC1idWJibGUtbGFiZWwiPkNvYWNoPC9kaXY+JyArCiAgICAgICAgICAgICc8ZGl2IGNsYXNzPSJjb2FjaC10eXBpbmctZG90cyI+PHNwYW4+PC9zcGFuPjxzcGFuPjwvc3Bhbj48c3Bhbj48L3NwYW4+PC9kaXY+JzsKICAgICAgICBjaGF0TWVzc2FnZXMuYXBwZW5kQ2hpbGQoZWwpOwogICAgICAgIHNjcm9sbENoYXRUb0JvdHRvbSgpOwogICAgICAgIHJldHVybiBlbDsKICAgIH0KCiAgICBmdW5jdGlvbiBzY3JvbGxDaGF0VG9Cb3R0b20oKSB7CiAgICAgICAgY2hhdE1lc3NhZ2VzLnNjcm9sbFRvcCA9IGNoYXRNZXNzYWdlcy5zY3JvbGxIZWlnaHQ7CiAgICB9CgogICAgZnVuY3Rpb24gY2xlYXJDaGF0KCkgewogICAgICAgIGNoYXRIaXN0b3J5ID0gW107CiAgICAgICAgY2hhdE1lc3NhZ2VzLmlubmVySFRNTCA9CiAgICAgICAgICAgICc8ZGl2IGNsYXNzPSJjb2FjaC1jaGF0LXdlbGNvbWUiPicgKwogICAgICAgICAgICAgICAgJzxkaXYgY2xhc3M9ImNvYWNoLWNoYXQt' +
'd2VsY29tZS1pY29uIj4mIzEyOTMwMjs8L2Rpdj4nICsKICAgICAgICAgICAgICAgICc8cD5TZWxlY3QgYSBkZWNrIGFib3ZlIGFuZCBhc2sgbWUgYW55dGhpbmcgYWJvdXQgeW91ciBzdHJhdGVneSwgY2FyZCBjaG9pY2VzLCBvciB1cGdyYWRlcy48L3A+JyArCiAgICAgICAgICAgICc8L2Rpdj4nOwogICAgfQoKICAgIGZ1bmN0aW9uIGZvcm1hdE1hcmtkb3duKHRleHQpIHsKICAgICAgICBpZiAoIXRleHQpIHJldHVybiAnJzsKICAgICAgICAvLyBCYXNpYyBtYXJrZG93bjogYm9sZCwgaXRhbGljLCBjb2RlLCBsaW5lIGJyZWFrcwogICAgICAgIHJldHVybiBlc2NIdG1sKHRleHQpCiAgICAgICAgICAgIC5yZXBsYWNlKC9cKlwqKC4rPylcKlwqL2csICc8c3Ryb25nPiQxPC9zdHJvbmc+JykKICAgICAgICAgICAgLnJlcGxhY2UoL1wqKC4rPylcKi9nLCAnPGVtPiQxPC9lbT4nKQogICAgICAgICAgICAucmVwbGFjZSgvYChbXmBdKylgL2csICc8Y29kZT4kMTwvY29kZT4nKQogICAgICAgICAgICAucmVwbGFjZSgvXG4vZywgJzxicj4nKTsKICAgIH0KCiAgICAvLyDilIDilIAgQ2FyZHMtTGlrZSBTZWFyY2gg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgYXN5bmMgZnVuY3Rpb24gc2VhcmNoQ2FyZHNMaWtlKCkgewogICAgICAgIGNvbnN0IGNhcmQgPSBjYXJkc2xpa2VJbnB1dC52YWx1ZS50cmltKCk7CiAgICAgICAgaWYgKCFjYXJkKSByZXR1cm47CgogICAgICAgIGNvbnN0IGFjdGl2ZUNvbG9ycyA9IFtdOwogICAgICAgIGRvY3VtZW50LnF1ZXJ5U2VsZWN0b3JBbGwoJy5jb2FjaC1jYXJkc2xpa2UtY29sb3JzIC5jb2FjaC1jb2xvci1waXAuYWN0aXZlJykuZm9yRWFjaChidG4gPT4gewogICAgICAgICAgICBhY3RpdmVDb2xvcnMucHVzaChidG4uZGF0YXNldC5jb2xvcik7CiAgICAgICAgfSk7CgogICAgICAgIGNvbnN0IHRvcE4gPSBwYXJzZUludChjYXJkc2xpa2VUb3BuLnZhbHVlKSB8fCAxMDsKCiAgICAgICAgY2FyZHNsaWtlUmVzdWx0cy5pbm5lckhUTUwgPSAnPGRpdiBjbGFzcz0iY29hY2gtY2FyZHNsaWtlLWxvYWRpbmciPjxkaXYgY2xhc3M9InNwaW5uZXIiPjwvZGl2PiBTZWFyY2hpbmcgZW1iZWRkaW5ncy4uLjwvZGl2Pic7CgogICAgICAgIHRyeSB7CiAgICAgICAgICAgIGxldCB1cmwgPSBBUEkgKyAnL2FwaS9jb2FjaC9jYXJkcy1saWtlP2NhcmQ9JyArIGVuY29kZVVSSUNvbXBvbmVudChjYXJkKSArICcmdG9wX249JyArIHRvcE47CiAgICAgICAgICAgIGlmIChhY3RpdmVDb2xvcnMubGVuZ3RoID4gMCkgewogICAgICAgICAgICAgICAgdXJsICs9ICcmY29sb3JzPScgKyBhY3RpdmVDb2xvcnMuam9pbignJyk7CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKHVybCk7CiAgICAgICAgICAgIGlmICghcmVzLm9rKSB7CiAgICAgICAgICAgICAgICBjb25zdCBlcnIgPSBhd2FpdCByZXMuanNvbigpLmNhdGNoKCgpID0+ICh7IGRldGFpbDogJ1NlYXJjaCBmYWlsZWQnIH0pKTsKICAgICAgICAgICAgICAgIHRocm93IG5ldyBFcnJvcihlcnIuZGV0YWlsIHx8ICdTZWFyY2ggZmFpbGVkJyk7CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGNvbnN0IGRhdGEgPSBhd2FpdCByZXMuanNvbigpOwogICAgICAgICAgICBjb25zdCByZXN1bHRzID0gZGF0YS5yZXN1bHRzIHx8IFtdOwoKICAgICAgICAgICAgaWYgKHJlc3VsdHMubGVuZ3RoID09PSAwKSB7CiAgICAgICAgICAgICAgICBjYXJkc2xpa2VSZXN1bHRzLmlubmVySFRNTCA9ICc8ZGl2IGNsYXNzPSJjb2FjaC1jYXJkc2xpa2UtZW1wdHkiPk5vIHNpbWlsYXIgY2FyZHMgZm91bmQ8L2Rpdj4nOwogICAgICAgICAgICAgICAgcmV0dXJuOwogICAgICAgICAgICB9CgogICAgICAgICAgICBjYXJkc2xpa2VSZXN1bHRzLmlubmVySFRNTCA9ICcnOwogICAgICAgICAgICBmb3IgKGNvbnN0IGNhcmQgb2YgcmVzdWx0cykgewogICAgICAgICAgICAgICAgY29uc3QgZGl2ID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2Jyk7CiAgICAgICAgICAgICAgICBkaXYuY2xhc3NOYW1lID0gJ2NvYWNoLWNhcmRzbGlrZS1jYXJkJzsKCiAgICAgICAgICAgICAgICBjb25zdCBzaW1QY3QgPSBNYXRoLnJvdW5kKGNhcmQuc2ltaWxhcml0eSAqIDEwMCk7CiAgICAgICAgICAgICAgICBjb25zdCBvd25lZEJhZGdlID0g' +
'Y2FyZC5vd25lZF9xdHkgPiAwCiAgICAgICAgICAgICAgICAgICAgPyAnPHNwYW4gY2xhc3M9ImNvYWNoLWNsLW93bmVkIj4nICsgY2FyZC5vd25lZF9xdHkgKyAnIG93bmVkPC9zcGFuPicKICAgICAgICAgICAgICAgICAgICA6ICc8c3BhbiBjbGFzcz0iY29hY2gtY2wtdW5vd25lZCI+bm90IG93bmVkPC9zcGFuPic7CiAgICAgICAgICAgICAgICBjb25zdCBwcmljZSA9IGNhcmQudGNnX3ByaWNlID8gJyQnICsgTnVtYmVyKGNhcmQudGNnX3ByaWNlKS50b0ZpeGVkKDIpIDogJyc7CgogICAgICAgICAgICAgICAgZGl2LmlubmVySFRNTCA9CiAgICAgICAgICAgICAgICAgICAgJzxkaXYgY2xhc3M9ImNvYWNoLWNsLXRvcCI+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICc8c3BhbiBjbGFzcz0iY29hY2gtY2wtbmFtZSI+JyArIGVzY0h0bWwoY2FyZC5uYW1lKSArICc8L3NwYW4+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICc8c3BhbiBjbGFzcz0iY29hY2gtY2wtc2ltIj4nICsgc2ltUGN0ICsgJyU8L3NwYW4+JyArCiAgICAgICAgICAgICAgICAgICAgJzwvZGl2PicgKwogICAgICAgICAgICAgICAgICAgICc8ZGl2IGNsYXNzPSJjb2FjaC1jbC1tZXRhIj4nICsKICAgICAgICAgICAgICAgICAgICAgICAgJzxzcGFuIGNsYXNzPSJjb2FjaC1jbC10eXBlIj4nICsgZXNjSHRtbChjYXJkLnR5cGVzIHx8ICcnKSArICc8L3NwYW4+JyArCiAgICAgICAgICAgICAgICAgICAgICAgIChjYXJkLm1hbmFfY29zdCA/ICc8c3BhbiBjbGFzcz0iY29hY2gtY2wtbWFuYSI+JyArIGVzY0h0bWwoY2FyZC5tYW5hX2Nvc3QpICsgJzwvc3Bhbj4nIDogJycpICsKICAgICAgICAgICAgICAgICAgICAgICAgb3duZWRCYWRnZSArCiAgICAgICAgICAgICAgICAgICAgICAgIChwcmljZSA/ICc8c3BhbiBjbGFzcz0iY29hY2gtY2wtcHJpY2UiPicgKyBwcmljZSArICc8L3NwYW4+JyA6ICcnKSArCiAgICAgICAgICAgICAgICAgICAgJzwvZGl2PicgKwogICAgICAgICAgICAgICAgICAgIChjYXJkLnRleHQgPyAnPGRpdiBjbGFzcz0iY29hY2gtY2wtdGV4dCI+JyArIGVzY0h0bWwoY2FyZC50ZXh0KSArICc8L2Rpdj4nIDogJycpICsKICAgICAgICAgICAgICAgICAgICAnPGRpdiBjbGFzcz0iY29hY2gtY2wtYWN0aW9ucyI+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICc8YnV0dG9uIGNsYXNzPSJidG4gYnRuLWdob3N0IGJ0bi14cyBjb2FjaC1jbC1jaGF0LWJ0biI+QWRkIHRvIENoYXQ8L2J1dHRvbj4nICsKICAgICAgICAgICAgICAgICAgICAnPC9kaXY+JzsKCiAgICAgICAgICAgICAgICBkaXYucXVlcnlTZWxlY3RvcignLmNvYWNoLWNsLWNoYXQtYnRuJykuYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCAoKSA9PiB7CiAgICAgICAgICAgICAgICAgICAgY2hhdElucHV0LnZhbHVlICs9IChjaGF0SW5wdXQudmFsdWUgPyAnICcgOiAnJykgKyBjYXJkLm5hbWU7CiAgICAgICAgICAgICAgICAgICAgY2hhdElucHV0LmZvY3VzKCk7CiAgICAgICAgICAgICAgICAgICAgc2hvd1RvYXN0KGNhcmQubmFtZSArICcgYWRkZWQgdG8gY2hhdCBpbnB1dCcsICdzdWNjZXNzJyk7CiAgICAgICAgICAgICAgICB9KTsKCiAgICAgICAgICAgICAgICBjYXJkc2xpa2VSZXN1bHRzLmFwcGVuZENoaWxkKGRpdik7CiAgICAgICAgICAgIH0KICAgICAgICB9IGNhdGNoIChlKSB7CiAgICAgICAgICAgIGNhcmRzbGlrZVJlc3VsdHMuaW5uZXJIVE1MID0gJzxkaXYgY2xhc3M9ImNvYWNoLWNhcmRzbGlrZS1lbXB0eSI+RXJyb3I6ICcgKyBlc2NIdG1sKGUubWVzc2FnZSkgKyAnPC9kaXY+JzsKICAgICAgICB9CiAgICB9CgogICAgLy8gQ29sb3IgcGlwIHRvZ2dsZQogICAgZG9jdW1lbnQucXVlcnlTZWxlY3RvckFsbCgnLmNvYWNoLWNhcmRzbGlrZS1jb2xvcnMgLmNvYWNoLWNvbG9yLXBpcCcpLmZvckVhY2goYnRuID0+IHsKICAgICAgICBidG4uYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCAoKSA9PiBidG4uY2xhc3NMaXN0LnRvZ2dsZSgnYWN0aXZlJykpOwogICAgfSk7CgogICAgLy8g4pSA4pSAIFRvYXN0IOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAoKICAgIGZ1bmN0aW9uIHNob3dUb2FzdChtZXNzYWdlLCB0eXBlKSB7CiAgICAgICAgY29uc3QgY29udGFpbmVyID0gZG9jdW1lbnQu' +
'Z2V0RWxlbWVudEJ5SWQoJ2NvYWNoLXRvYXN0LWNvbnRhaW5lcicpOwogICAgICAgIGNvbnN0IHRvYXN0ID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2Jyk7CiAgICAgICAgdG9hc3QuY2xhc3NOYW1lID0gJ2NvYWNoLXRvYXN0IGNvYWNoLXRvYXN0LScgKyAodHlwZSB8fCAnaW5mbycpOwogICAgICAgIHRvYXN0LnRleHRDb250ZW50ID0gbWVzc2FnZTsKICAgICAgICBjb250YWluZXIuYXBwZW5kQ2hpbGQodG9hc3QpOwoKICAgICAgICBzZXRUaW1lb3V0KCgpID0+IHRvYXN0LmNsYXNzTGlzdC5hZGQoJ2NvYWNoLXRvYXN0LXNob3cnKSwgMTApOwogICAgICAgIHNldFRpbWVvdXQoKCkgPT4gewogICAgICAgICAgICB0b2FzdC5jbGFzc0xpc3QucmVtb3ZlKCdjb2FjaC10b2FzdC1zaG93Jyk7CiAgICAgICAgICAgIHNldFRpbWVvdXQoKCkgPT4gdG9hc3QucmVtb3ZlKCksIDMwMCk7CiAgICAgICAgfSwgNDAwMCk7CiAgICB9CgogICAgLy8g4pSA4pSAIEhlbHBlcnMg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgZnVuY3Rpb24gYWRkTWV0YUNoaXAobGFiZWwsIHZhbHVlKSB7CiAgICAgICAgY29uc3QgY2hpcCA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoJ3NwYW4nKTsKICAgICAgICBjaGlwLmNsYXNzTmFtZSA9ICdjb2FjaC1tZXRhLWNoaXAnOwogICAgICAgIGNoaXAuaW5uZXJIVE1MID0gJzxzdHJvbmc+JyArIGVzY0h0bWwobGFiZWwpICsgJzo8L3N0cm9uZz4gJyArIGVzY0h0bWwodmFsdWUpOwogICAgICAgIG1ldGFSb3cuYXBwZW5kQ2hpbGQoY2hpcCk7CiAgICB9CgogICAgZnVuY3Rpb24gZm9ybWF0SW1wYWN0KHNjb3JlKSB7CiAgICAgICAgaWYgKHNjb3JlID09PSB1bmRlZmluZWQgfHwgc2NvcmUgPT09IG51bGwpIHsKICAgICAgICAgICAgcmV0dXJuICc8c3BhbiBjbGFzcz0iY29hY2gtaW1wYWN0IGNvYWNoLWltcGFjdC1uZXV0cmFsIj5cdTIwMTQ8L3NwYW4+JzsKICAgICAgICB9CiAgICAgICAgY29uc3QgZml4ZWQgPSBzY29yZS50b0ZpeGVkKDMpOwogICAgICAgIGlmIChzY29yZSA8IC0wLjAxKSB7CiAgICAgICAgICAgIHJldHVybiAnPHNwYW4gY2xhc3M9ImNvYWNoLWltcGFjdCBjb2FjaC1pbXBhY3QtbmVnYXRpdmUiPicgKyBmaXhlZCArICc8L3NwYW4+JzsKICAgICAgICB9IGVsc2UgaWYgKHNjb3JlID4gMC4wMSkgewogICAgICAgICAgICByZXR1cm4gJzxzcGFuIGNsYXNzPSJjb2FjaC1pbXBhY3QgY29hY2gtaW1wYWN0LXBvc2l0aXZlIj4rJyArIGZpeGVkICsgJzwvc3Bhbj4nOwogICAgICAgIH0gZWxzZSB7CiAgICAgICAgICAgIHJldHVybiAnPHNwYW4gY2xhc3M9ImNvYWNoLWltcGFjdCBjb2FjaC1pbXBhY3QtbmV1dHJhbCI+JyArIGZpeGVkICsgJzwvc3Bhbj4nOwogICAgICAgIH0KICAgIH0KCiAgICBmdW5jdGlvbiBmb3JtYXRUaW1lKGlzb1N0cikgewogICAgICAgIHRyeSB7CiAgICAgICAgICAgIGNvbnN0IGQgPSBuZXcgRGF0ZShpc29TdHIpOwogICAgICAgICAgICByZXR1cm4gZC50b0xvY2FsZURhdGVTdHJpbmcoKSArICcgJyArIGQudG9Mb2NhbGVUaW1lU3RyaW5nKFtdLCB7IGhvdXI6ICcyLWRpZ2l0JywgbWludXRlOiAnMi1kaWdpdCcgfSk7CiAgICAgICAgfSBjYXRjaCB7CiAgICAgICAgICAgIHJldHVybiBpc29TdHI7CiAgICAgICAgfQogICAgfQoKICAgIGZ1bmN0aW9uIGVzY0h0bWwoc3RyKSB7CiAgICAgICAgaWYgKCFzdHIpIHJldHVybiAnJzsKICAgICAgICByZXR1cm4gU3RyaW5nKHN0cikKICAgICAgICAgICAgLnJlcGxhY2UoLyYvZywgJyZhbXA7JykKICAgICAgICAgICAgLnJlcGxhY2UoLzwvZywgJyZsdDsnKQogICAgICAgICAgICAucmVwbGFjZSgvPi9nLCAnJmd0OycpCiAgICAgICAgICAgIC5yZXBsYWNlKC8iL2csICcmcXVvdDsnKTsKICAgIH0KCn0pKCk7Cg=='
'ICd3YXJuJywgJ05vbmUgXHUyMDE0IHJ1biBzaW11bGF0aW9ucyBmaXJzdCcpOwogICAgICAgICAgICAgICAgZ2VuUmVwb3J0c0J0bi5zdHlsZS5kaXNwbGF5ID0gJ2lubGluZS1ibG9jayc7CiAgICAgICAgICAgICAgICBnZW5SZXBvcnRzQnRuLnRleHRDb250ZW50ID0gJ0dlbmVyYXRlIFJlcG9ydHMnOwogICAgICAgICAgICB9CiAgICAgICAgfSBjYXRjaCAoZSkgewogICAgICAgICAgICBzZXRTdGF0dXMoc3RhdHVzTGxtRG90LCBzdGF0dXNMbG1WYWx1ZSwgJ2Vycm9yJywgJ1NlcnZpY2UgdW5hdmFpbGFibGUnKTsKICAgICAgICAgICAgc2V0U3RhdHVzKHN0YXR1c0VtYkRvdCwgc3RhdHVzRW1iVmFsdWUsICdlcnJvcicsICdcdTIwMTQnKTsKICAgICAgICAgICAgc2V0U3RhdHVzKHN0YXR1c1JlcERvdCwgc3RhdHVzUmVwVmFsdWUsICdlcnJvcicsICdcdTIwMTQnKTsKICAgICAgICB9CiAgICB9CgogICAgZnVuY3Rpb24gc2V0U3RhdHVzKGRvdEVsLCB2YWx1ZUVsLCBsZXZlbCwgdGV4dCkgewogICAgICAgIGRvdEVsLmNsYXNzTmFtZSA9ICdzdGF0dXMtZG90IHN0YXR1cy0nICsgbGV2ZWw7CiAgICAgICAgdmFsdWVFbC50ZXh0Q29udGVudCA9IHRleHQ7CiAgICB9CgogICAgLy8g4pSA4pSAIExvYWQgRGVja3Mg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgYXN5bmMgZnVuY3Rpb24gbG9hZERlY2tzKCkgewogICAgICAgIHRyeSB7CiAgICAgICAgICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKEFQSSArICcvYXBpL2NvYWNoL2RlY2tzJyk7CiAgICAgICAgICAgIGNvbnN0IGRhdGEgPSBhd2FpdCByZXMuanNvbigpOwogICAgICAgICAgICBjb25zdCBkZWNrcyA9IGRhdGEuZGVja3MgfHwgW107CgogICAgICAgICAgICB3aGlsZSAoZGVja1NlbGVjdC5vcHRpb25zLmxlbmd0aCA+IDEpIHsKICAgICAgICAgICAgICAgIGRlY2tTZWxlY3QucmVtb3ZlKDEpOwogICAgICAgICAgICB9CgogICAgICAgICAgICBpZiAoZGVja3MubGVuZ3RoID09PSAwKSB7CiAgICAgICAgICAgICAgICBjb25zdCBvcHQgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCdvcHRpb24nKTsKICAgICAgICAgICAgICAgIG9wdC5kaXNhYmxlZCA9IHRydWU7CiAgICAgICAgICAgICAgICBvcHQudGV4dENvbnRlbnQgPSAnTm8gZGVjayByZXBvcnRzIGF2YWlsYWJsZSBcdTIwMTQgcnVuIHNpbXVsYXRpb25zIGZpcnN0JzsKICAgICAgICAgICAgICAgIGRlY2tTZWxlY3QuYXBwZW5kQ2hpbGQob3B0KTsKICAgICAgICAgICAgICAgIHJldHVybjsKICAgICAgICAgICAgfQoKICAgICAgICAgICAgZGVja3Muc29ydCgpOwogICAgICAgICAgICBmb3IgKGNvbnN0IGRlY2tJZCBvZiBkZWNrcykgewogICAgICAgICAgICAgICAgY29uc3Qgb3B0ID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnb3B0aW9uJyk7CiAgICAgICAgICAgICAgICBvcHQudmFsdWUgPSBkZWNrSWQ7CiAgICAgICAgICAgICAgICBvcHQudGV4dENvbnRlbnQgPSBkZWNrSWQ7CiAgICAgICAgICAgICAgICBkZWNrU2VsZWN0LmFwcGVuZENoaWxkKG9wdCk7CiAgICAgICAgICAgIH0KICAgICAgICB9IGNhdGNoIChlKSB7CiAgICAgICAgICAgIGNvbnNvbGUuZXJyb3IoJ0ZhaWxlZCB0byBsb2FkIGRlY2tzOicsIGUpOwogICAgICAgIH0KICAgIH0KCiAgICAvLyDilIDilIAgTG9hZCBEQiBEZWNrcyAoZm9yIEFwcGx5KSDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKCiAgICBhc3luYyBmdW5jdGlvbiBsb2FkRGJEZWNrcygpIHsKICAgICAgICB0cnkgewogICAgICAgICAgICBjb25zdCByZXMgPSBhd2FpdCBmZXRjaChBUEkgKyAnL2FwaS9kZWNrcycpOwogICAgICAgICAgICBpZiAoIXJlcy5vaykgcmV0dXJuOwogICAgICAgICAgICBjb25zdCBkYXRhID0gYXdhaXQgcmVzLmpzb24oKTsKICAgICAgICAgICAgZGJEZWNrcyA9IGRhdGEuZGVja3MgfHwgW107CgogICAgICAgICAgICB3aGlsZSAoYXBwbHlEZWNrU2VsZWN0Lm9wdGlvbnMubGVuZ3RoID4gMSkgewogICAgICAgICAgICAgICAgYXBwbHlEZWNrU2VsZWN0LnJlbW92ZSgxKTsKICAgICAgICAgICAgfQoKICAgICAgICAgICAgZm9yIChjb25zdCBkZWNrIG9mIGRiRGVja3MpIHsKICAgICAgICAg' +
'ICAgICAgIGNvbnN0IG9wdCA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoJ29wdGlvbicpOwogICAgICAgICAgICAgICAgb3B0LnZhbHVlID0gZGVjay5pZDsKICAgICAgICAgICAgICAgIG9wdC50ZXh0Q29udGVudCA9IGRlY2submFtZSArIChkZWNrLmNvbW1hbmRlciA/ICcgKCcgKyBkZWNrLmNvbW1hbmRlciArICcpJyA6ICcnKTsKICAgICAgICAgICAgICAgIGFwcGx5RGVja1NlbGVjdC5hcHBlbmRDaGlsZChvcHQpOwogICAgICAgICAgICB9CiAgICAgICAgfSBjYXRjaCAoZSkgewogICAgICAgICAgICBjb25zb2xlLmVycm9yKCdGYWlsZWQgdG8gbG9hZCBEQiBkZWNrczonLCBlKTsKICAgICAgICB9CiAgICB9CgogICAgLy8g4pSA4pSAIExvYWQgUGFzdCBTZXNzaW9ucyDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIDilIAKCiAgICBhc3luYyBmdW5jdGlvbiBsb2FkU2Vzc2lvbnMoKSB7CiAgICAgICAgdHJ5IHsKICAgICAgICAgICAgY29uc3QgcmVzID0gYXdhaXQgZmV0Y2goQVBJICsgJy9hcGkvY29hY2gvc2Vzc2lvbnMnKTsKICAgICAgICAgICAgY29uc3QgZGF0YSA9IGF3YWl0IHJlcy5qc29uKCk7CiAgICAgICAgICAgIGNvbnN0IHNlc3Npb25zID0gZGF0YS5zZXNzaW9ucyB8fCBbXTsKCiAgICAgICAgICAgIGlmIChzZXNzaW9ucy5sZW5ndGggPT09IDApIHsKICAgICAgICAgICAgICAgIHNlc3Npb25zTGlzdC5pbm5lckhUTUwgPSAnPHAgY2xhc3M9ImNvYWNoLWVtcHR5Ij5ObyBjb2FjaGluZyBzZXNzaW9ucyB5ZXQuIFJ1biB0aGUgY29hY2ggdG8gZ2V0IHN0YXJ0ZWQuPC9wPic7CiAgICAgICAgICAgICAgICByZXR1cm47CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIHNlc3Npb25zTGlzdC5pbm5lckhUTUwgPSAnJzsKICAgICAgICAgICAgZm9yIChjb25zdCBzIG9mIHNlc3Npb25zKSB7CiAgICAgICAgICAgICAgICBjb25zdCBpdGVtID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2Jyk7CiAgICAgICAgICAgICAgICBpdGVtLmNsYXNzTmFtZSA9ICdjb2FjaC1zZXNzaW9uLWl0ZW0nOwogICAgICAgICAgICAgICAgaXRlbS5kYXRhc2V0LnNlc3Npb25JZCA9IHMuc2Vzc2lvbklkOwoKICAgICAgICAgICAgICAgIGNvbnN0IHRpbWVTdHIgPSBzLnRpbWVzdGFtcCA/IGZvcm1hdFRpbWUocy50aW1lc3RhbXApIDogJyc7CiAgICAgICAgICAgICAgICBjb25zdCBpc0V4cGFuZGVkID0gZXhwYW5kZWRTZXNzaW9uc1tzLnNlc3Npb25JZF07CgogICAgICAgICAgICAgICAgaXRlbS5pbm5lckhUTUwgPQogICAgICAgICAgICAgICAgICAgICc8ZGl2IGNsYXNzPSJjb2FjaC1zZXNzaW9uLWhlYWRlciIgZGF0YS1zaWQ9IicgKyBlc2NIdG1sKHMuc2Vzc2lvbklkKSArICciPicgKwogICAgICAgICAgICAgICAgICAgICAgICAnPHNwYW4gY2xhc3M9ImNvYWNoLXNlc3Npb24tZXhwYW5kLWFycm93Ij4nICsgKGlzRXhwYW5kZWQgPyAnXHUyNUJDJyA6ICdcdTI1QjYnKSArICc8L3NwYW4+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICc8c3BhbiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1kZWNrIj4nICsgZXNjSHRtbChzLmRlY2tJZCkgKyAnPC9zcGFuPicgKwogICAgICAgICAgICAgICAgICAgICAgICAnPHNwYW4gY2xhc3M9ImNvYWNoLXNlc3Npb24tc3VtbWFyeSI+JyArIGVzY0h0bWwocy5zdW1tYXJ5IHx8ICcobm8gc3VtbWFyeSknKSArICc8L3NwYW4+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICc8c3BhbiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1tZXRhIj4nICsKICAgICAgICAgICAgICAgICAgICAgICAgICAgICc8c3BhbiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1jdXRzIj4tJyArIChzLmN1dHNDb3VudCB8fCAwKSArICc8L3NwYW4+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAnPHNwYW4gY2xhc3M9ImNvYWNoLXNlc3Npb24tYWRkcyI+KycgKyAocy5hZGRzQ291bnQgfHwgMCkgKyAnPC9zcGFuPicgKwogICAgICAgICAgICAgICAgICAgICAgICAgICAgJzxzcGFuIGNsYXNzPSJjb2FjaC1zZXNzaW9uLXRpbWUiPicgKyBlc2NIdG1sKHRpbWVTdHIpICsgJzwvc3Bhbj4nICsKICAgICAgICAgICAgICAgICAgICAgICAgJzwvc3Bhbj4nICsKICAgICAgICAgICAgICAgICAgICAnPC9kaXY+JyArCiAgICAgICAgICAgICAgICAgICAgJzxkaXYgY2xhc3M9ImNvYWNoLXNlc3Npb24tZGV0YWlsIiBpZD0ic2Vzc2lv' +
'bi1kZXRhaWwtJyArIGVzY0h0bWwocy5zZXNzaW9uSWQpICsgJyIgc3R5bGU9IicgKyAoaXNFeHBhbmRlZCA/ICcnIDogJ2Rpc3BsYXk6bm9uZScpICsgJyI+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICc8ZGl2IGNsYXNzPSJjb2FjaC1zZXNzaW9uLWRldGFpbC1sb2FkaW5nIj5Mb2FkaW5nLi4uPC9kaXY+JyArCiAgICAgICAgICAgICAgICAgICAgJzwvZGl2Pic7CgogICAgICAgICAgICAgICAgLy8gQ2xpY2sgaGFuZGxlciBmb3IgZXhwYW5kL2NvbGxhcHNlCiAgICAgICAgICAgICAgICBpdGVtLnF1ZXJ5U2VsZWN0b3IoJy5jb2FjaC1zZXNzaW9uLWhlYWRlcicpLmFkZEV2ZW50TGlzdGVuZXIoJ2NsaWNrJywgZnVuY3Rpb24gKCkgewogICAgICAgICAgICAgICAgICAgIHRvZ2dsZVNlc3Npb24ocy5zZXNzaW9uSWQpOwogICAgICAgICAgICAgICAgfSk7CgogICAgICAgICAgICAgICAgc2Vzc2lvbnNMaXN0LmFwcGVuZENoaWxkKGl0ZW0pOwoKICAgICAgICAgICAgICAgIC8vIElmIHByZXZpb3VzbHkgZXhwYW5kZWQsIGxvYWQgZGV0YWlsCiAgICAgICAgICAgICAgICBpZiAoaXNFeHBhbmRlZCAmJiBzZXNzaW9uQ2FjaGVbcy5zZXNzaW9uSWRdKSB7CiAgICAgICAgICAgICAgICAgICAgcmVuZGVyU2Vzc2lvbkRldGFpbChzLnNlc3Npb25JZCwgc2Vzc2lvbkNhY2hlW3Muc2Vzc2lvbklkXSk7CiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KICAgICAgICB9IGNhdGNoIChlKSB7CiAgICAgICAgICAgIGNvbnNvbGUuZXJyb3IoJ0ZhaWxlZCB0byBsb2FkIHNlc3Npb25zOicsIGUpOwogICAgICAgIH0KICAgIH0KCiAgICBhc3luYyBmdW5jdGlvbiB0b2dnbGVTZXNzaW9uKHNlc3Npb25JZCkgewogICAgICAgIGNvbnN0IGRldGFpbEVsID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3Nlc3Npb24tZGV0YWlsLScgKyBzZXNzaW9uSWQpOwogICAgICAgIGNvbnN0IGhlYWRlckVsID0gZGV0YWlsRWw/LnByZXZpb3VzRWxlbWVudFNpYmxpbmc7CiAgICAgICAgY29uc3QgYXJyb3dFbCA9IGhlYWRlckVsPy5xdWVyeVNlbGVjdG9yKCcuY29hY2gtc2Vzc2lvbi1leHBhbmQtYXJyb3cnKTsKCiAgICAgICAgaWYgKCFkZXRhaWxFbCkgcmV0dXJuOwoKICAgICAgICBjb25zdCB3YXNIaWRkZW4gPSBkZXRhaWxFbC5zdHlsZS5kaXNwbGF5ID09PSAnbm9uZSc7CgogICAgICAgIGlmICh3YXNIaWRkZW4pIHsKICAgICAgICAgICAgZGV0YWlsRWwuc3R5bGUuZGlzcGxheSA9ICdibG9jayc7CiAgICAgICAgICAgIGV4cGFuZGVkU2Vzc2lvbnNbc2Vzc2lvbklkXSA9IHRydWU7CiAgICAgICAgICAgIGlmIChhcnJvd0VsKSBhcnJvd0VsLnRleHRDb250ZW50ID0gJ1x1MjVCQyc7CgogICAgICAgICAgICBpZiAoIXNlc3Npb25DYWNoZVtzZXNzaW9uSWRdKSB7CiAgICAgICAgICAgICAgICB0cnkgewogICAgICAgICAgICAgICAgICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKEFQSSArICcvYXBpL2NvYWNoL3Nlc3Npb25zLycgKyBlbmNvZGVVUklDb21wb25lbnQoc2Vzc2lvbklkKSk7CiAgICAgICAgICAgICAgICAgICAgaWYgKCFyZXMub2spIHRocm93IG5ldyBFcnJvcignU2Vzc2lvbiBub3QgZm91bmQnKTsKICAgICAgICAgICAgICAgICAgICBjb25zdCBzZXNzaW9uID0gYXdhaXQgcmVzLmpzb24oKTsKICAgICAgICAgICAgICAgICAgICBzZXNzaW9uQ2FjaGVbc2Vzc2lvbklkXSA9IHNlc3Npb247CiAgICAgICAgICAgICAgICAgICAgcmVuZGVyU2Vzc2lvbkRldGFpbChzZXNzaW9uSWQsIHNlc3Npb24pOwogICAgICAgICAgICAgICAgfSBjYXRjaCAoZSkgewogICAgICAgICAgICAgICAgICAgIGRldGFpbEVsLmlubmVySFRNTCA9ICc8ZGl2IGNsYXNzPSJjb2FjaC1zZXNzaW9uLWRldGFpbC1lcnJvciI+RmFpbGVkIHRvIGxvYWQgc2Vzc2lvbiBkZXRhaWxzPC9kaXY+JzsKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfSBlbHNlIHsKICAgICAgICAgICAgICAgIHJlbmRlclNlc3Npb25EZXRhaWwoc2Vzc2lvbklkLCBzZXNzaW9uQ2FjaGVbc2Vzc2lvbklkXSk7CiAgICAgICAgICAgIH0KICAgICAgICB9IGVsc2UgewogICAgICAgICAgICBkZXRhaWxFbC5zdHlsZS5kaXNwbGF5ID0gJ25vbmUnOwogICAgICAgICAgICBleHBhbmRlZFNlc3Npb25zW3Nlc3Npb25JZF0gPSBmYWxzZTsKICAgICAgICAgICAgaWYgKGFycm93RWwpIGFycm93RWwudGV4dENvbnRlbnQgPSAnXHUyNUI2JzsKICAgICAgICB9CiAgICB9CgogICAgZnVuY3Rpb24gcmVuZGVyU2Vzc2lv' +
'bkRldGFpbChzZXNzaW9uSWQsIHNlc3Npb24pIHsKICAgICAgICBjb25zdCBkZXRhaWxFbCA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdzZXNzaW9uLWRldGFpbC0nICsgc2Vzc2lvbklkKTsKICAgICAgICBpZiAoIWRldGFpbEVsKSByZXR1cm47CgogICAgICAgIGNvbnN0IGN1dHMgPSBzZXNzaW9uLnN1Z2dlc3RlZEN1dHMgfHwgW107CiAgICAgICAgY29uc3QgYWRkcyA9IHNlc3Npb24uc3VnZ2VzdGVkQWRkcyB8fCBbXTsKCiAgICAgICAgbGV0IGh0bWwgPSAnPGRpdiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1kZXRhaWwtaW5uZXIiPic7CgogICAgICAgIC8vIFN1bW1hcnkKICAgICAgICBpZiAoc2Vzc2lvbi5zdW1tYXJ5KSB7CiAgICAgICAgICAgIGh0bWwgKz0gJzxwIGNsYXNzPSJjb2FjaC1zZXNzaW9uLWRldGFpbC1zdW1tYXJ5Ij4nICsgZXNjSHRtbChzZXNzaW9uLnN1bW1hcnkpICsgJzwvcD4nOwogICAgICAgIH0KCiAgICAgICAgLy8gQ3V0cwogICAgICAgIGlmIChjdXRzLmxlbmd0aCA+IDApIHsKICAgICAgICAgICAgaHRtbCArPSAnPGRpdiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1kZXRhaWwtZ3JvdXAiPic7CiAgICAgICAgICAgIGh0bWwgKz0gJzxzcGFuIGNsYXNzPSJjb2FjaC1zZXNzaW9uLWRldGFpbC1sYWJlbCI+Q3V0czo8L3NwYW4+JzsKICAgICAgICAgICAgaHRtbCArPSAnPGRpdiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1waWxscyI+JzsKICAgICAgICAgICAgZm9yIChjb25zdCBjdXQgb2YgY3V0cykgewogICAgICAgICAgICAgICAgaHRtbCArPSAnPHNwYW4gY2xhc3M9ImNvYWNoLXBpbGwgY29hY2gtcGlsbC1jdXQiIHRpdGxlPSInICsgZXNjSHRtbChjdXQucmVhc29uIHx8ICcnKSArICciPicgKyBlc2NIdG1sKGN1dC5jYXJkTmFtZSkgKyAnPC9zcGFuPic7CiAgICAgICAgICAgIH0KICAgICAgICAgICAgaHRtbCArPSAnPC9kaXY+PC9kaXY+JzsKICAgICAgICB9CgogICAgICAgIC8vIEFkZHMKICAgICAgICBpZiAoYWRkcy5sZW5ndGggPiAwKSB7CiAgICAgICAgICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9ImNvYWNoLXNlc3Npb24tZGV0YWlsLWdyb3VwIj4nOwogICAgICAgICAgICBodG1sICs9ICc8c3BhbiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1kZXRhaWwtbGFiZWwiPkFkZHM6PC9zcGFuPic7CiAgICAgICAgICAgIGh0bWwgKz0gJzxkaXYgY2xhc3M9ImNvYWNoLXNlc3Npb24tcGlsbHMiPic7CiAgICAgICAgICAgIGZvciAoY29uc3QgYWRkIG9mIGFkZHMpIHsKICAgICAgICAgICAgICAgIGh0bWwgKz0gJzxzcGFuIGNsYXNzPSJjb2FjaC1waWxsIGNvYWNoLXBpbGwtYWRkIiB0aXRsZT0iJyArIGVzY0h0bWwoYWRkLnJlYXNvbiB8fCAnJykgKyAnIj4nICsgZXNjSHRtbChhZGQuY2FyZE5hbWUpICsgJzwvc3Bhbj4nOwogICAgICAgICAgICB9CiAgICAgICAgICAgIGh0bWwgKz0gJzwvZGl2PjwvZGl2Pic7CiAgICAgICAgfQoKICAgICAgICAvLyBMb2FkIGluIENoYXQgYnV0dG9uCiAgICAgICAgaHRtbCArPSAnPGRpdiBjbGFzcz0iY29hY2gtc2Vzc2lvbi1kZXRhaWwtYWN0aW9ucyI+JzsKICAgICAgICBodG1sICs9ICc8YnV0dG9uIGNsYXNzPSJidG4gYnRuLWdob3N0IGJ0bi1zbSBjb2FjaC1sb2FkLWNoYXQtYnRuIiBkYXRhLXNpZD0iJyArIGVzY0h0bWwoc2Vzc2lvbklkKSArICciPkxvYWQgaW4gQ2hhdDwvYnV0dG9uPic7CiAgICAgICAgaHRtbCArPSAnPGJ1dHRvbiBjbGFzcz0iYnRuIGJ0bi1naG9zdCBidG4tc20gY29hY2gtdmlldy1mdWxsLWJ0biIgZGF0YS1zaWQ9IicgKyBlc2NIdG1sKHNlc3Npb25JZCkgKyAnIj5WaWV3IEZ1bGwgUmVzdWx0czwvYnV0dG9uPic7CiAgICAgICAgaHRtbCArPSAnPC9kaXY+JzsKCiAgICAgICAgaHRtbCArPSAnPC9kaXY+JzsKICAgICAgICBkZXRhaWxFbC5pbm5lckhUTUwgPSBodG1sOwoKICAgICAgICAvLyBCaW5kIGJ1dHRvbnMKICAgICAgICBkZXRhaWxFbC5xdWVyeVNlbGVjdG9yKCcuY29hY2gtbG9hZC1jaGF0LWJ0bicpPy5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsIChlKSA9PiB7CiAgICAgICAgICAgIGUuc3RvcFByb3BhZ2F0aW9uKCk7CiAgICAgICAgICAgIGxvYWRTZXNzaW9uSW5DaGF0KHNlc3Npb25JZCk7CiAgICAgICAgfSk7CiAgICAgICAgZGV0YWlsRWwucXVlcnlTZWxlY3RvcignLmNvYWNoLXZpZXctZnVsbC1idG4nKT8uYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCAoZSkgPT4gewogICAgICAgICAgICBlLnN0b3BQcm9wYWdhdGlvbigpOwogICAgICAgICAgICBkaXNw' +
'bGF5U2Vzc2lvbihzZXNzaW9uQ2FjaGVbc2Vzc2lvbklkXSk7CiAgICAgICAgICAgIHJlc3VsdHNFbC5zY3JvbGxJbnRvVmlldyh7IGJlaGF2aW9yOiAnc21vb3RoJywgYmxvY2s6ICdzdGFydCcgfSk7CiAgICAgICAgfSk7CiAgICB9CgogICAgZnVuY3Rpb24gbG9hZFNlc3Npb25JbkNoYXQoc2Vzc2lvbklkKSB7CiAgICAgICAgY29uc3Qgc2Vzc2lvbiA9IHNlc3Npb25DYWNoZVtzZXNzaW9uSWRdOwogICAgICAgIGlmICghc2Vzc2lvbikgcmV0dXJuOwoKICAgICAgICBjbGVhckNoYXQoKTsKCiAgICAgICAgLy8gU2V0IGRlY2sgc2VsZWN0b3IgaWYgcG9zc2libGUKICAgICAgICBpZiAoc2Vzc2lvbi5kZWNrSWQpIHsKICAgICAgICAgICAgZm9yIChsZXQgaSA9IDA7IGkgPCBkZWNrU2VsZWN0Lm9wdGlvbnMubGVuZ3RoOyBpKyspIHsKICAgICAgICAgICAgICAgIGlmIChkZWNrU2VsZWN0Lm9wdGlvbnNbaV0udmFsdWUgPT09IHNlc3Npb24uZGVja0lkKSB7CiAgICAgICAgICAgICAgICAgICAgZGVja1NlbGVjdC52YWx1ZSA9IHNlc3Npb24uZGVja0lkOwogICAgICAgICAgICAgICAgICAgIGRlY2tTZWxlY3QuZGlzcGF0Y2hFdmVudChuZXcgRXZlbnQoJ2NoYW5nZScpKTsKICAgICAgICAgICAgICAgICAgICBicmVhazsKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgLy8gUHJlLWZpbGwgY2hhdCB3aXRoIGEgc3VtbWFyeSBjb250ZXh0CiAgICAgICAgY29uc3QgY29udGV4dE1zZyA9ICdCYXNlZCBvbiBhIHByZXZpb3VzIGNvYWNoaW5nIHNlc3Npb24gZm9yIHRoaXMgZGVjaywgaGVyZSB3YXMgdGhlIGFuYWx5c2lzOlxuXG4nICsKICAgICAgICAgICAgKHNlc3Npb24uc3VtbWFyeSB8fCAnJykgKyAnXG5cbicgKwogICAgICAgICAgICAnU3VnZ2VzdGVkIGN1dHM6ICcgKyAoc2Vzc2lvbi5zdWdnZXN0ZWRDdXRzIHx8IFtdKS5tYXAoYyA9PiBjLmNhcmROYW1lKS5qb2luKCcsICcpICsgJ1xuJyArCiAgICAgICAgICAgICdTdWdnZXN0ZWQgYWRkczogJyArIChzZXNzaW9uLnN1Z2dlc3RlZEFkZHMgfHwgW10pLm1hcChhID0+IGEuY2FyZE5hbWUpLmpvaW4oJywgJyk7CgogICAgICAgIGNoYXRIaXN0b3J5LnB1c2goeyByb2xlOiAnYXNzaXN0YW50JywgY29udGVudDogY29udGV4dE1zZyB9KTsKICAgICAgICBhcHBlbmRDaGF0QnViYmxlKCdhc3Npc3RhbnQnLCBjb250ZXh0TXNnKTsKCiAgICAgICAgY2hhdElucHV0LmZvY3VzKCk7CiAgICAgICAgc2hvd1RvYXN0KCdTZXNzaW9uIGxvYWRlZCBpbnRvIGNoYXQnLCAnc3VjY2VzcycpOwogICAgfQoKICAgIC8vIOKUgOKUgCBHZW5lcmF0ZSBSZXBvcnRzIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAoKICAgIGFzeW5jIGZ1bmN0aW9uIGdlbmVyYXRlUmVwb3J0cygpIHsKICAgICAgICBnZW5SZXBvcnRzQnRuLmRpc2FibGVkID0gdHJ1ZTsKICAgICAgICBnZW5SZXBvcnRzQnRuLnRleHRDb250ZW50ID0gJ0dlbmVyYXRpbmcuLi4nOwoKICAgICAgICB0cnkgewogICAgICAgICAgICBjb25zdCByZXMgPSBhd2FpdCBmZXRjaChBUEkgKyAnL2FwaS9jb2FjaC9yZXBvcnRzL2dlbmVyYXRlJywgeyBtZXRob2Q6ICdQT1NUJyB9KTsKICAgICAgICAgICAgY29uc3QgZGF0YSA9IGF3YWl0IHJlcy5qc29uKCk7CgogICAgICAgICAgICBpZiAoZGF0YS5jb3VudCA+IDApIHsKICAgICAgICAgICAgICAgIGdlblJlcG9ydHNCdG4udGV4dENvbnRlbnQgPSBkYXRhLmNvdW50ICsgJyByZXBvcnRzIGdlbmVyYXRlZCc7CiAgICAgICAgICAgICAgICBhd2FpdCBjaGVja1N0YXR1cygpOwogICAgICAgICAgICAgICAgYXdhaXQgbG9hZERlY2tzKCk7CiAgICAgICAgICAgIH0gZWxzZSB7CiAgICAgICAgICAgICAgICBnZW5SZXBvcnRzQnRuLnRleHRDb250ZW50ID0gJ05vIHJlc3VsdHMgZm91bmQnOwogICAgICAgICAgICB9CiAgICAgICAgfSBjYXRjaCAoZSkgewogICAgICAgICAgICBnZW5SZXBvcnRzQnRuLnRleHRDb250ZW50ID0gJ0Vycm9yOiAnICsgZS5tZXNzYWdlOwogICAgICAgIH0gZmluYWxseSB7CiAgICAgICAgICAgIHNldFRpbWVvdXQoKCkgPT4gewogICAgICAgICAgICAgICAgZ2VuUmVwb3J0c0J0bi5kaXNhYmxlZCA9IGZhbHNlOwogICAgICAgICAgICAgICAgZ2VuUmVwb3J0c0J0bi50ZXh0Q29udGVudCA9ICdSZWJ1aWxkIFJlcG9y' +
'dHMnOwogICAgICAgICAgICB9LCAzMDAwKTsKICAgICAgICB9CiAgICB9CgogICAgLy8g4pSA4pSAIFJ1biBDb2FjaCAob25lLXNob3QpIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAoKICAgIGFzeW5jIGZ1bmN0aW9uIHJ1bkNvYWNoKCkgewogICAgICAgIGNvbnN0IGRlY2tJZCA9IGRlY2tTZWxlY3QudmFsdWU7CiAgICAgICAgaWYgKCFkZWNrSWQpIHJldHVybjsKCiAgICAgICAgY29uc3QgZ29hbHMgPSBnZXRHb2FscygpOwoKICAgICAgICBydW5CdG4uZGlzYWJsZWQgPSB0cnVlOwogICAgICAgIHByb2dyZXNzRWwuc3R5bGUuZGlzcGxheSA9ICdmbGV4JzsKICAgICAgICBwcm9ncmVzc1RleHQudGV4dENvbnRlbnQgPSAnQW5hbHl6aW5nIGRlY2sgYW5kIGNvbnN1bHRpbmcgTExNLi4uIFRoaXMgbWF5IHRha2UgMzAtNjAgc2Vjb25kcy4nOwogICAgICAgIHJlc3VsdHNFbC5zdHlsZS5kaXNwbGF5ID0gJ25vbmUnOwoKICAgICAgICBjb25zdCBwcmV2RXJyb3IgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCcuY29hY2gtZXJyb3InKTsKICAgICAgICBpZiAocHJldkVycm9yKSBwcmV2RXJyb3IucmVtb3ZlKCk7CgogICAgICAgIHRyeSB7CiAgICAgICAgICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKEFQSSArICcvYXBpL2NvYWNoL2RlY2tzLycgKyBlbmNvZGVVUklDb21wb25lbnQoZGVja0lkKSwgewogICAgICAgICAgICAgICAgbWV0aG9kOiAnUE9TVCcsCiAgICAgICAgICAgICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSwKICAgICAgICAgICAgICAgIGJvZHk6IEpTT04uc3RyaW5naWZ5KHsgZ29hbHM6IGdvYWxzIH0pLAogICAgICAgICAgICB9KTsKCiAgICAgICAgICAgIGlmICghcmVzLm9rKSB7CiAgICAgICAgICAgICAgICBjb25zdCBlcnIgPSBhd2FpdCByZXMuanNvbigpLmNhdGNoKCgpID0+ICh7IGRldGFpbDogJ1Vua25vd24gZXJyb3InIH0pKTsKICAgICAgICAgICAgICAgIHRocm93IG5ldyBFcnJvcihlcnIuZGV0YWlsIHx8ICdDb2FjaCByZXF1ZXN0IGZhaWxlZCAoJyArIHJlcy5zdGF0dXMgKyAnKScpOwogICAgICAgICAgICB9CgogICAgICAgICAgICBjb25zdCBzZXNzaW9uID0gYXdhaXQgcmVzLmpzb24oKTsKICAgICAgICAgICAgY3VycmVudFNlc3Npb25JZCA9IHNlc3Npb24uc2Vzc2lvbklkIHx8IG51bGw7CiAgICAgICAgICAgIGRpc3BsYXlTZXNzaW9uKHNlc3Npb24pOwogICAgICAgICAgICBsb2FkU2Vzc2lvbnMoKTsKICAgICAgICB9IGNhdGNoIChlKSB7CiAgICAgICAgICAgIGNvbnN0IGVyckRpdiA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoJ2RpdicpOwogICAgICAgICAgICBlcnJEaXYuY2xhc3NOYW1lID0gJ2NvYWNoLWVycm9yJzsKICAgICAgICAgICAgZXJyRGl2LnRleHRDb250ZW50ID0gJ0NvYWNoIGVycm9yOiAnICsgZS5tZXNzYWdlOwogICAgICAgICAgICBkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCcuY29hY2gtY29uZmlnJykuYXBwZW5kQ2hpbGQoZXJyRGl2KTsKICAgICAgICB9IGZpbmFsbHkgewogICAgICAgICAgICBydW5CdG4uZGlzYWJsZWQgPSBmYWxzZTsKICAgICAgICAgICAgcHJvZ3Jlc3NFbC5zdHlsZS5kaXNwbGF5ID0gJ25vbmUnOwogICAgICAgIH0KICAgIH0KCiAgICAvLyDilIDilIAgRGlzcGxheSBTZXNzaW9uIFJlc3VsdHMg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgZnVuY3Rpb24gZGlzcGxheVNlc3Npb24oc2Vzc2lvbikgewogICAgICAgIHJlc3VsdHNFbC5zdHlsZS5kaXNwbGF5ID0gJ2ZsZXgnOwogICAgICAgIGN1cnJlbnRTZXNzaW9uSWQgPSBzZXNzaW9uLnNlc3Npb25JZCB8fCBjdXJyZW50U2Vzc2lvbklkOwoKICAgICAgICBzdW1tYXJ5VGV4dC50ZXh0Q29udGVudCA9IHNlc3Npb24uc3VtbWFyeSB8fCAnKE5vIHN1bW1hcnkgcHJvdmlkZWQpJzsKCiAgICAgICAgbWV0YVJvdy5pbm5lckhUTUwgPSAnJzsKICAgICAgICBpZiAoc2Vzc2lvbi5tb2RlbFVzZWQpIGFkZE1ldGFDaGlwKCdNb2RlbCcsIHNlc3Npb24ubW9kZWxVc2VkKTsKICAgICAgICBpZiAoc2Vzc2lvbi5wcm9tcHRUb2tlbnMpIGFkZE1ldGFDaGlwKCdQcm9tcHQnLCBzZXNzaW9uLnByb21wdFRva2Vucy50b0xvY2FsZVN0cmlu' +
'ZygpICsgJyB0b2tlbnMnKTsKICAgICAgICBpZiAoc2Vzc2lvbi5jb21wbGV0aW9uVG9rZW5zKSBhZGRNZXRhQ2hpcCgnQ29tcGxldGlvbicsIHNlc3Npb24uY29tcGxldGlvblRva2Vucy50b0xvY2FsZVN0cmluZygpICsgJyB0b2tlbnMnKTsKICAgICAgICBpZiAoc2Vzc2lvbi5kZWNrSWQpIGFkZE1ldGFDaGlwKCdEZWNrJywgc2Vzc2lvbi5kZWNrSWQpOwogICAgICAgIGlmIChzZXNzaW9uLnRpbWVzdGFtcCkgYWRkTWV0YUNoaXAoJ1RpbWUnLCBmb3JtYXRUaW1lKHNlc3Npb24udGltZXN0YW1wKSk7CgogICAgICAgIC8vIEN1dHMgdGFibGUKICAgICAgICBjb25zdCBjdXRzID0gc2Vzc2lvbi5zdWdnZXN0ZWRDdXRzIHx8IFtdOwogICAgICAgIGN1dHNDb3VudC50ZXh0Q29udGVudCA9IGN1dHMubGVuZ3RoOwogICAgICAgIGN1dHNCb2R5LmlubmVySFRNTCA9ICcnOwogICAgICAgIGN1dHNBbGxDaGVjay5jaGVja2VkID0gZmFsc2U7CgogICAgICAgIGZvciAoY29uc3QgY3V0IG9mIGN1dHMpIHsKICAgICAgICAgICAgY29uc3QgdHIgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCd0cicpOwogICAgICAgICAgICB0ci5pbm5lckhUTUwgPQogICAgICAgICAgICAgICAgJzx0ZD48aW5wdXQgdHlwZT0iY2hlY2tib3giIGRhdGEtY2FyZD0iJyArIGVzY0h0bWwoY3V0LmNhcmROYW1lKSArICciIC8+PC90ZD4nICsKICAgICAgICAgICAgICAgICc8dGQ+PHNwYW4gY2xhc3M9ImNvYWNoLWNhcmQtbmFtZSI+JyArIGVzY0h0bWwoY3V0LmNhcmROYW1lKSArICc8L3NwYW4+PC90ZD4nICsKICAgICAgICAgICAgICAgICc8dGQ+JyArIGVzY0h0bWwoY3V0LnJlYXNvbikgKyAnPC90ZD4nICsKICAgICAgICAgICAgICAgICc8dGQ+PGRpdiBjbGFzcz0iY29hY2gtcmVwbGFjZW1lbnRzIj4nICsKICAgICAgICAgICAgICAgICAgICAoY3V0LnJlcGxhY2VtZW50T3B0aW9ucyB8fCBbXSkubWFwKHIgPT4KICAgICAgICAgICAgICAgICAgICAgICAgJzxzcGFuIGNsYXNzPSJjb2FjaC1yZXBsYWNlbWVudC1jaGlwIj4nICsgZXNjSHRtbChyKSArICc8L3NwYW4+JwogICAgICAgICAgICAgICAgICAgICkuam9pbignJykgKwogICAgICAgICAgICAgICAgJzwvZGl2PjwvdGQ+JyArCiAgICAgICAgICAgICAgICAnPHRkPicgKyBmb3JtYXRJbXBhY3QoY3V0LmN1cnJlbnRJbXBhY3RTY29yZSkgKyAnPC90ZD4nOwogICAgICAgICAgICBjdXRzQm9keS5hcHBlbmRDaGlsZCh0cik7CiAgICAgICAgfQoKICAgICAgICAvLyBBZGRzIHRhYmxlCiAgICAgICAgY29uc3QgYWRkcyA9IHNlc3Npb24uc3VnZ2VzdGVkQWRkcyB8fCBbXTsKICAgICAgICBhZGRzQ291bnQudGV4dENvbnRlbnQgPSBhZGRzLmxlbmd0aDsKICAgICAgICBhZGRzQm9keS5pbm5lckhUTUwgPSAnJzsKICAgICAgICBhZGRzQWxsQ2hlY2suY2hlY2tlZCA9IGZhbHNlOwoKICAgICAgICBmb3IgKGNvbnN0IGFkZCBvZiBhZGRzKSB7CiAgICAgICAgICAgIGNvbnN0IHRyID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgndHInKTsKICAgICAgICAgICAgdHIuaW5uZXJIVE1MID0KICAgICAgICAgICAgICAgICc8dGQ+PGlucHV0IHR5cGU9ImNoZWNrYm94IiBkYXRhLWNhcmQ9IicgKyBlc2NIdG1sKGFkZC5jYXJkTmFtZSkgKyAnIiAvPjwvdGQ+JyArCiAgICAgICAgICAgICAgICAnPHRkPjxzcGFuIGNsYXNzPSJjb2FjaC1jYXJkLW5hbWUiPicgKyBlc2NIdG1sKGFkZC5jYXJkTmFtZSkgKyAnPC9zcGFuPjwvdGQ+JyArCiAgICAgICAgICAgICAgICAnPHRkPicgKyAoYWRkLnJvbGUgPyAnPHNwYW4gY2xhc3M9ImNvYWNoLXJvbGUtYmFkZ2UiPicgKyBlc2NIdG1sKGFkZC5yb2xlKSArICc8L3NwYW4+JyA6ICcnKSArICc8L3RkPicgKwogICAgICAgICAgICAgICAgJzx0ZD4nICsgZXNjSHRtbChhZGQucmVhc29uKSArICc8L3RkPicgKwogICAgICAgICAgICAgICAgJzx0ZD48ZGl2IGNsYXNzPSJjb2FjaC1yZXBsYWNlbWVudHMiPicgKwogICAgICAgICAgICAgICAgICAgIChhZGQuc3luZXJneVdpdGggfHwgW10pLm1hcChzID0+CiAgICAgICAgICAgICAgICAgICAgICAgICc8c3BhbiBjbGFzcz0iY29hY2gtc3luZXJneS1jaGlwIj4nICsgZXNjSHRtbChzKSArICc8L3NwYW4+JwogICAgICAgICAgICAgICAgICAgICkuam9pbignJykgKwogICAgICAgICAgICAgICAgJzwvZGl2PjwvdGQ+JzsKICAgICAgICAgICAgYWRkc0JvZHkuYXBwZW5kQ2hpbGQodHIpOwogICAgICAgIH0KCiAgICAgICAgLy8gSGludHMKICAgICAgICBjb25zdCBoaW50' +
'cyA9IHNlc3Npb24uaGV1cmlzdGljSGludHMgfHwgW107CiAgICAgICAgaWYgKGhpbnRzLmxlbmd0aCA+IDApIHsKICAgICAgICAgICAgaGludHNDYXJkLnN0eWxlLmRpc3BsYXkgPSAnYmxvY2snOwogICAgICAgICAgICBoaW50c0xpc3QuaW5uZXJIVE1MID0gJyc7CiAgICAgICAgICAgIGZvciAoY29uc3QgaGludCBvZiBoaW50cykgewogICAgICAgICAgICAgICAgY29uc3QgbGkgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCdsaScpOwogICAgICAgICAgICAgICAgbGkudGV4dENvbnRlbnQgPSBoaW50OwogICAgICAgICAgICAgICAgaGludHNMaXN0LmFwcGVuZENoaWxkKGxpKTsKICAgICAgICAgICAgfQogICAgICAgIH0gZWxzZSB7CiAgICAgICAgICAgIGhpbnRzQ2FyZC5zdHlsZS5kaXNwbGF5ID0gJ25vbmUnOwogICAgICAgIH0KCiAgICAgICAgLy8gTWFuYSBhZHZpY2UKICAgICAgICBpZiAoc2Vzc2lvbi5tYW5hQmFzZUFkdmljZSkgewogICAgICAgICAgICBtYW5hQ2FyZC5zdHlsZS5kaXNwbGF5ID0gJ2Jsb2NrJzsKICAgICAgICAgICAgbWFuYVRleHQudGV4dENvbnRlbnQgPSBzZXNzaW9uLm1hbmFCYXNlQWR2aWNlOwogICAgICAgIH0gZWxzZSB7CiAgICAgICAgICAgIG1hbmFDYXJkLnN0eWxlLmRpc3BsYXkgPSAnbm9uZSc7CiAgICAgICAgfQoKICAgICAgICAvLyBSYXcgdGV4dAogICAgICAgIHJhd1RleHQudGV4dENvbnRlbnQgPSBzZXNzaW9uLnJhd1RleHRFeHBsYW5hdGlvbiB8fCBzZXNzaW9uLnN1bW1hcnkgfHwgJyhlbXB0eSknOwoKICAgICAgICB1cGRhdGVBcHBseUNvdW50KCk7CiAgICB9CgogICAgLy8g4pSA4pSAIEFwcGx5IFN1Z2dlc3Rpb25zIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAoKICAgIGZ1bmN0aW9uIHVwZGF0ZUFwcGx5Q291bnQoKSB7CiAgICAgICAgY29uc3QgY3V0Q2hlY2tlZCA9IGN1dHNCb2R5LnF1ZXJ5U2VsZWN0b3JBbGwoJ2lucHV0W3R5cGU9ImNoZWNrYm94Il06Y2hlY2tlZCcpOwogICAgICAgIGNvbnN0IGFkZENoZWNrZWQgPSBhZGRzQm9keS5xdWVyeVNlbGVjdG9yQWxsKCdpbnB1dFt0eXBlPSJjaGVja2JveCJdOmNoZWNrZWQnKTsKICAgICAgICBjb25zdCB0b3RhbCA9IGN1dENoZWNrZWQubGVuZ3RoICsgYWRkQ2hlY2tlZC5sZW5ndGg7CgogICAgICAgIGFwcGx5Q291bnRFbC50ZXh0Q29udGVudCA9IGN1dENoZWNrZWQubGVuZ3RoICsgJyBjdXRzLCAnICsgYWRkQ2hlY2tlZC5sZW5ndGggKyAnIGFkZHMgc2VsZWN0ZWQnOwogICAgICAgIGFwcGx5QnRuLmRpc2FibGVkID0gdG90YWwgPT09IDAgfHwgIWFwcGx5RGVja1NlbGVjdC52YWx1ZTsKICAgIH0KCiAgICBhcHBseURlY2tTZWxlY3QuYWRkRXZlbnRMaXN0ZW5lcignY2hhbmdlJywgdXBkYXRlQXBwbHlDb3VudCk7CgogICAgYXN5bmMgZnVuY3Rpb24gYXBwbHlTdWdnZXN0aW9ucygpIHsKICAgICAgICBjb25zdCBkZWNrSWQgPSBwYXJzZUludChhcHBseURlY2tTZWxlY3QudmFsdWUpOwogICAgICAgIGlmICghZGVja0lkKSB7CiAgICAgICAgICAgIHNob3dUb2FzdCgnU2VsZWN0IGEgZGVjayB0byBhcHBseSBzdWdnZXN0aW9ucyB0bycsICd3YXJuaW5nJyk7CiAgICAgICAgICAgIHJldHVybjsKICAgICAgICB9CgogICAgICAgIGNvbnN0IGFjY2VwdGVkQ3V0cyA9IFtdOwogICAgICAgIGN1dHNCb2R5LnF1ZXJ5U2VsZWN0b3JBbGwoJ2lucHV0W3R5cGU9ImNoZWNrYm94Il06Y2hlY2tlZCcpLmZvckVhY2goY2IgPT4gewogICAgICAgICAgICBjb25zdCBuYW1lID0gY2IuZGF0YXNldC5jYXJkOwogICAgICAgICAgICBpZiAobmFtZSkgYWNjZXB0ZWRDdXRzLnB1c2gobmFtZSk7CiAgICAgICAgfSk7CgogICAgICAgIGNvbnN0IGFjY2VwdGVkQWRkcyA9IFtdOwogICAgICAgIGFkZHNCb2R5LnF1ZXJ5U2VsZWN0b3JBbGwoJ2lucHV0W3R5cGU9ImNoZWNrYm94Il06Y2hlY2tlZCcpLmZvckVhY2goY2IgPT4gewogICAgICAgICAgICBjb25zdCBuYW1lID0gY2IuZGF0YXNldC5jYXJkOwogICAgICAgICAgICBpZiAobmFtZSkgYWNjZXB0ZWRBZGRzLnB1c2gobmFtZSk7CiAgICAgICAgfSk7CgogICAgICAgIGlmIChhY2NlcHRlZEN1dHMubGVuZ3RoID09PSAwICYmIGFjY2VwdGVkQWRkcy5sZW5ndGggPT09IDApIHsKICAgICAgICAgICAgc2hvd1RvYXN0KCdObyBzdWdnZXN0aW9ucyBzZWxlY3RlZCcsICd3YXJuaW5n' +
'Jyk7CiAgICAgICAgICAgIHJldHVybjsKICAgICAgICB9CgogICAgICAgIGFwcGx5QnRuLmRpc2FibGVkID0gdHJ1ZTsKICAgICAgICBhcHBseUJ0bi50ZXh0Q29udGVudCA9ICdBcHBseWluZy4uLic7CgogICAgICAgIHRyeSB7CiAgICAgICAgICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKEFQSSArICcvYXBpL2NvYWNoL2FwcGx5JywgewogICAgICAgICAgICAgICAgbWV0aG9kOiAnUE9TVCcsCiAgICAgICAgICAgICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSwKICAgICAgICAgICAgICAgIGJvZHk6IEpTT04uc3RyaW5naWZ5KHsKICAgICAgICAgICAgICAgICAgICBzZXNzaW9uX2lkOiBjdXJyZW50U2Vzc2lvbklkIHx8ICdtYW51YWwnLAogICAgICAgICAgICAgICAgICAgIGRlY2tfaWQ6IGRlY2tJZCwKICAgICAgICAgICAgICAgICAgICBhY2NlcHRlZF9jdXRzOiBhY2NlcHRlZEN1dHMsCiAgICAgICAgICAgICAgICAgICAgYWNjZXB0ZWRfYWRkczogYWNjZXB0ZWRBZGRzLAogICAgICAgICAgICAgICAgfSksCiAgICAgICAgICAgIH0pOwoKICAgICAgICAgICAgaWYgKCFyZXMub2spIHsKICAgICAgICAgICAgICAgIGNvbnN0IGVyciA9IGF3YWl0IHJlcy5qc29uKCkuY2F0Y2goKCkgPT4gKHsgZGV0YWlsOiAnQXBwbHkgZmFpbGVkJyB9KSk7CiAgICAgICAgICAgICAgICB0aHJvdyBuZXcgRXJyb3IoZXJyLmRldGFpbCB8fCAnQXBwbHkgZmFpbGVkJyk7CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGNvbnN0IHJlc3VsdCA9IGF3YWl0IHJlcy5qc29uKCk7CiAgICAgICAgICAgIGNvbnN0IG1zZyA9ICdBcHBsaWVkOiAnICsgcmVzdWx0LnRvdGFsX2N1dHMgKyAnIGN1dHMsICcgKyByZXN1bHQudG90YWxfYWRkcyArICcgYWRkcycgKwogICAgICAgICAgICAgICAgKHJlc3VsdC5lcnJvcnMubGVuZ3RoID4gMCA/ICcgKCcgKyByZXN1bHQuZXJyb3JzLmxlbmd0aCArICcgZXJyb3JzKScgOiAnJyk7CiAgICAgICAgICAgIHNob3dUb2FzdChtc2csIHJlc3VsdC5lcnJvcnMubGVuZ3RoID4gMCA/ICd3YXJuaW5nJyA6ICdzdWNjZXNzJyk7CgogICAgICAgIH0gY2F0Y2ggKGUpIHsKICAgICAgICAgICAgc2hvd1RvYXN0KCdBcHBseSBlcnJvcjogJyArIGUubWVzc2FnZSwgJ2Vycm9yJyk7CiAgICAgICAgfSBmaW5hbGx5IHsKICAgICAgICAgICAgYXBwbHlCdG4uZGlzYWJsZWQgPSBmYWxzZTsKICAgICAgICAgICAgYXBwbHlCdG4udGV4dENvbnRlbnQgPSAnQXBwbHkgU3VnZ2VzdGlvbnMnOwogICAgICAgICAgICB1cGRhdGVBcHBseUNvdW50KCk7CiAgICAgICAgfQogICAgfQoKICAgIC8vIOKUgOKUgCBNdWx0aS10dXJuIENoYXQg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgYXN5bmMgZnVuY3Rpb24gc2VuZENoYXRNZXNzYWdlKCkgewogICAgICAgIGNvbnN0IHRleHQgPSBjaGF0SW5wdXQudmFsdWUudHJpbSgpOwogICAgICAgIGlmICghdGV4dCB8fCAhZGVja1NlbGVjdC52YWx1ZSB8fCBpc1N0cmVhbWluZykgcmV0dXJuOwoKICAgICAgICAvLyBBZGQgdXNlciBtZXNzYWdlCiAgICAgICAgY2hhdEhpc3RvcnkucHVzaCh7IHJvbGU6ICd1c2VyJywgY29udGVudDogdGV4dCB9KTsKICAgICAgICBhcHBlbmRDaGF0QnViYmxlKCd1c2VyJywgdGV4dCk7CiAgICAgICAgY2hhdElucHV0LnZhbHVlID0gJyc7CiAgICAgICAgY2hhdFNlbmRCdG4uZGlzYWJsZWQgPSB0cnVlOwoKICAgICAgICAvLyBTaG93IHR5cGluZyBpbmRpY2F0b3IKICAgICAgICBjb25zdCB0eXBpbmdFbCA9IHNob3dUeXBpbmdJbmRpY2F0b3IoKTsKICAgICAgICBpc1N0cmVhbWluZyA9IHRydWU7CgogICAgICAgIHRyeSB7CiAgICAgICAgICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKEFQSSArICcvYXBpL2NvYWNoL2NoYXQnLCB7CiAgICAgICAgICAgICAgICBtZXRob2Q6ICdQT1NUJywKICAgICAgICAgICAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LAogICAgICAgICAgICAgICAgYm9keTogSlNPTi5zdHJpbmdpZnkoewogICAgICAgICAgICAgICAgICAgIGRlY2tfaWQ6IGRlY2tTZWxlY3QudmFsdWUsCiAgICAgICAgICAgICAgICAgICAgbWVzc2FnZXM6IGNoYXRIaXN0b3J5LAogICAgICAgICAgICAgICAgICAgIGdvYWxzOiBnZXRH' +
'b2FscygpLAogICAgICAgICAgICAgICAgICAgIHN0cmVhbTogdHJ1ZSwKICAgICAgICAgICAgICAgIH0pLAogICAgICAgICAgICB9KTsKCiAgICAgICAgICAgIGlmICghcmVzLm9rKSB7CiAgICAgICAgICAgICAgICBjb25zdCBlcnIgPSBhd2FpdCByZXMuanNvbigpLmNhdGNoKCgpID0+ICh7IGRldGFpbDogJ0NoYXQgZmFpbGVkJyB9KSk7CiAgICAgICAgICAgICAgICB0aHJvdyBuZXcgRXJyb3IoZXJyLmRldGFpbCB8fCAnQ2hhdCBmYWlsZWQgKCcgKyByZXMuc3RhdHVzICsgJyknKTsKICAgICAgICAgICAgfQoKICAgICAgICAgICAgLy8gUmVtb3ZlIHR5cGluZyBpbmRpY2F0b3IsIGFkZCBhc3Npc3RhbnQgYnViYmxlCiAgICAgICAgICAgIHR5cGluZ0VsLnJlbW92ZSgpOwogICAgICAgICAgICBjb25zdCBidWJibGVFbCA9IGFwcGVuZENoYXRCdWJibGUoJ2Fzc2lzdGFudCcsICcnKTsKICAgICAgICAgICAgY29uc3QgY29udGVudEVsID0gYnViYmxlRWwucXVlcnlTZWxlY3RvcignLmNvYWNoLWNoYXQtYnViYmxlLWNvbnRlbnQnKTsKCiAgICAgICAgICAgIGxldCBmdWxsQ29udGVudCA9ICcnOwoKICAgICAgICAgICAgLy8gUmVhZCBTU0Ugc3RyZWFtCiAgICAgICAgICAgIGNvbnN0IHJlYWRlciA9IHJlcy5ib2R5LmdldFJlYWRlcigpOwogICAgICAgICAgICBjb25zdCBkZWNvZGVyID0gbmV3IFRleHREZWNvZGVyKCk7CiAgICAgICAgICAgIGxldCBidWZmZXIgPSAnJzsKCiAgICAgICAgICAgIHdoaWxlICh0cnVlKSB7CiAgICAgICAgICAgICAgICBjb25zdCB7IGRvbmUsIHZhbHVlIH0gPSBhd2FpdCByZWFkZXIucmVhZCgpOwogICAgICAgICAgICAgICAgaWYgKGRvbmUpIGJyZWFrOwoKICAgICAgICAgICAgICAgIGJ1ZmZlciArPSBkZWNvZGVyLmRlY29kZSh2YWx1ZSwgeyBzdHJlYW06IHRydWUgfSk7CiAgICAgICAgICAgICAgICBjb25zdCBsaW5lcyA9IGJ1ZmZlci5zcGxpdCgnXG4nKTsKICAgICAgICAgICAgICAgIGJ1ZmZlciA9IGxpbmVzLnBvcCgpIHx8ICcnOwoKICAgICAgICAgICAgICAgIGZvciAoY29uc3QgbGluZSBvZiBsaW5lcykgewogICAgICAgICAgICAgICAgICAgIGNvbnN0IHRyaW1tZWQgPSBsaW5lLnRyaW0oKTsKICAgICAgICAgICAgICAgICAgICBpZiAoIXRyaW1tZWQuc3RhcnRzV2l0aCgnZGF0YTogJykpIGNvbnRpbnVlOwoKICAgICAgICAgICAgICAgICAgICBjb25zdCBwYXlsb2FkID0gdHJpbW1lZC5zbGljZSg2KTsKICAgICAgICAgICAgICAgICAgICBpZiAocGF5bG9hZCA9PT0gJ1tET05FXScpIGJyZWFrOwoKICAgICAgICAgICAgICAgICAgICB0cnkgewogICAgICAgICAgICAgICAgICAgICAgICBjb25zdCBjaHVuayA9IEpTT04ucGFyc2UocGF5bG9hZCk7CiAgICAgICAgICAgICAgICAgICAgICAgIGlmIChjaHVuay5jb250ZW50KSB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICBmdWxsQ29udGVudCArPSBjaHVuay5jb250ZW50OwogICAgICAgICAgICAgICAgICAgICAgICAgICAgY29udGVudEVsLmlubmVySFRNTCA9IGZvcm1hdE1hcmtkb3duKGZ1bGxDb250ZW50KTsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIHNjcm9sbENoYXRUb0JvdHRvbSgpOwogICAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgfSBjYXRjaCAoZSkgewogICAgICAgICAgICAgICAgICAgICAgICAvLyBza2lwIG1hbGZvcm1lZCBjaHVua3MKICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIC8vIEZpbmFsaXplCiAgICAgICAgICAgIGNoYXRIaXN0b3J5LnB1c2goeyByb2xlOiAnYXNzaXN0YW50JywgY29udGVudDogZnVsbENvbnRlbnQgfSk7CiAgICAgICAgICAgIGNvbnRlbnRFbC5pbm5lckhUTUwgPSBmb3JtYXRNYXJrZG93bihmdWxsQ29udGVudCk7CiAgICAgICAgICAgIHNjcm9sbENoYXRUb0JvdHRvbSgpOwoKICAgICAgICB9IGNhdGNoIChlKSB7CiAgICAgICAgICAgIHR5cGluZ0VsLnJlbW92ZSgpOwogICAgICAgICAgICBhcHBlbmRDaGF0QnViYmxlKCdzeXN0ZW0nLCAnRXJyb3I6ICcgKyBlLm1lc3NhZ2UpOwogICAgICAgIH0gZmluYWxseSB7CiAgICAgICAgICAgIGlzU3RyZWFtaW5nID0gZmFsc2U7CiAgICAgICAgICAgIGNoYXRTZW5kQnRuLmRpc2FibGVkID0gIWNoYXRJbnB1dC52YWx1ZS50cmltKCkgfHwgIWRlY2tTZWxlY3QudmFsdWU7CiAgICAgICAgfQogICAgfQoKICAgIGZ1bmN0aW9uIGFwcGVuZENoYXRCdWJibGUo' +
'cm9sZSwgY29udGVudCkgewogICAgICAgIC8vIFJlbW92ZSB3ZWxjb21lIG1lc3NhZ2UKICAgICAgICBjb25zdCB3ZWxjb21lID0gY2hhdE1lc3NhZ2VzLnF1ZXJ5U2VsZWN0b3IoJy5jb2FjaC1jaGF0LXdlbGNvbWUnKTsKICAgICAgICBpZiAod2VsY29tZSkgd2VsY29tZS5yZW1vdmUoKTsKCiAgICAgICAgY29uc3QgYnViYmxlID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2Jyk7CiAgICAgICAgYnViYmxlLmNsYXNzTmFtZSA9ICdjb2FjaC1jaGF0LWJ1YmJsZSBjb2FjaC1jaGF0LScgKyByb2xlOwoKICAgICAgICBjb25zdCBsYWJlbCA9IHJvbGUgPT09ICd1c2VyJyA/ICdZb3UnIDogcm9sZSA9PT0gJ2Fzc2lzdGFudCcgPyAnQ29hY2gnIDogJ1N5c3RlbSc7CiAgICAgICAgYnViYmxlLmlubmVySFRNTCA9CiAgICAgICAgICAgICc8ZGl2IGNsYXNzPSJjb2FjaC1jaGF0LWJ1YmJsZS1sYWJlbCI+JyArIGxhYmVsICsgJzwvZGl2PicgKwogICAgICAgICAgICAnPGRpdiBjbGFzcz0iY29hY2gtY2hhdC1idWJibGUtY29udGVudCI+JyArIChjb250ZW50ID8gZm9ybWF0TWFya2Rvd24oY29udGVudCkgOiAnJykgKyAnPC9kaXY+JzsKCiAgICAgICAgY2hhdE1lc3NhZ2VzLmFwcGVuZENoaWxkKGJ1YmJsZSk7CiAgICAgICAgc2Nyb2xsQ2hhdFRvQm90dG9tKCk7CiAgICAgICAgcmV0dXJuIGJ1YmJsZTsKICAgIH0KCiAgICBmdW5jdGlvbiBzaG93VHlwaW5nSW5kaWNhdG9yKCkgewogICAgICAgIGNvbnN0IGVsID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2Jyk7CiAgICAgICAgZWwuY2xhc3NOYW1lID0gJ2NvYWNoLWNoYXQtdHlwaW5nJzsKICAgICAgICBlbC5pbm5lckhUTUwgPQogICAgICAgICAgICAnPGRpdiBjbGFzcz0iY29hY2gtY2hhdC1idWJibGUtbGFiZWwiPkNvYWNoPC9kaXY+JyArCiAgICAgICAgICAgICc8ZGl2IGNsYXNzPSJjb2FjaC10eXBpbmctZG90cyI+PHNwYW4+PC9zcGFuPjxzcGFuPjwvc3Bhbj48c3Bhbj48L3NwYW4+PC9kaXY+JzsKICAgICAgICBjaGF0TWVzc2FnZXMuYXBwZW5kQ2hpbGQoZWwpOwogICAgICAgIHNjcm9sbENoYXRUb0JvdHRvbSgpOwogICAgICAgIHJldHVybiBlbDsKICAgIH0KCiAgICBmdW5jdGlvbiBzY3JvbGxDaGF0VG9Cb3R0b20oKSB7CiAgICAgICAgY2hhdE1lc3NhZ2VzLnNjcm9sbFRvcCA9IGNoYXRNZXNzYWdlcy5zY3JvbGxIZWlnaHQ7CiAgICB9CgogICAgZnVuY3Rpb24gY2xlYXJDaGF0KCkgewogICAgICAgIGNoYXRIaXN0b3J5ID0gW107CiAgICAgICAgY2hhdE1lc3NhZ2VzLmlubmVySFRNTCA9CiAgICAgICAgICAgICc8ZGl2IGNsYXNzPSJjb2FjaC1jaGF0LXdlbGNvbWUiPicgKwogICAgICAgICAgICAgICAgJzxkaXYgY2xhc3M9ImNvYWNoLWNoYXQtd2VsY29tZS1pY29uIj4mIzEyOTMwMjs8L2Rpdj4nICsKICAgICAgICAgICAgICAgICc8cD5TZWxlY3QgYSBkZWNrIGFib3ZlIGFuZCBhc2sgbWUgYW55dGhpbmcgYWJvdXQgeW91ciBzdHJhdGVneSwgY2FyZCBjaG9pY2VzLCBvciB1cGdyYWRlcy48L3A+JyArCiAgICAgICAgICAgICc8L2Rpdj4nOwogICAgfQoKICAgIGZ1bmN0aW9uIGZvcm1hdE1hcmtkb3duKHRleHQpIHsKICAgICAgICBpZiAoIXRleHQpIHJldHVybiAnJzsKICAgICAgICAvLyBCYXNpYyBtYXJrZG93bjogYm9sZCwgaXRhbGljLCBjb2RlLCBsaW5lIGJyZWFrcwogICAgICAgIHJldHVybiBlc2NIdG1sKHRleHQpCiAgICAgICAgICAgIC5yZXBsYWNlKC9cKlwqKC4rPylcKlwqL2csICc8c3Ryb25nPiQxPC9zdHJvbmc+JykKICAgICAgICAgICAgLnJlcGxhY2UoL1wqKC4rPylcKi9nLCAnPGVtPiQxPC9lbT4nKQogICAgICAgICAgICAucmVwbGFjZSgvYChbXmBdKylgL2csICc8Y29kZT4kMTwvY29kZT4nKQogICAgICAgICAgICAucmVwbGFjZSgvXG4vZywgJzxicj4nKTsKICAgIH0KCiAgICAvLyDilIDilIAgQ2FyZHMtTGlrZSBTZWFyY2gg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgYXN5bmMgZnVuY3Rpb24gc2VhcmNoQ2FyZHNMaWtlKCkgewogICAgICAgIGNvbnN0IGNhcmQgPSBjYXJkc2xpa2VJbnB1dC52YWx1ZS50cmltKCk7CiAgICAgICAgaWYgKCFjYXJkKSByZXR1cm47CgogICAgICAgIGNvbnN0IGFjdGl2ZUNvbG9ycyA9IFtdOwogICAgICAgIGRvY3VtZW50LnF1ZXJ5U2VsZWN0b3JBbGwoJy5jb2FjaC1jYXJk' +
'c2xpa2UtY29sb3JzIC5jb2FjaC1jb2xvci1waXAuYWN0aXZlJykuZm9yRWFjaChidG4gPT4gewogICAgICAgICAgICBhY3RpdmVDb2xvcnMucHVzaChidG4uZGF0YXNldC5jb2xvcik7CiAgICAgICAgfSk7CgogICAgICAgIGNvbnN0IHRvcE4gPSBwYXJzZUludChjYXJkc2xpa2VUb3BuLnZhbHVlKSB8fCAxMDsKCiAgICAgICAgY2FyZHNsaWtlUmVzdWx0cy5pbm5lckhUTUwgPSAnPGRpdiBjbGFzcz0iY29hY2gtY2FyZHNsaWtlLWxvYWRpbmciPjxkaXYgY2xhc3M9InNwaW5uZXIiPjwvZGl2PiBTZWFyY2hpbmcgZW1iZWRkaW5ncy4uLjwvZGl2Pic7CgogICAgICAgIHRyeSB7CiAgICAgICAgICAgIGxldCB1cmwgPSBBUEkgKyAnL2FwaS9jb2FjaC9jYXJkcy1saWtlP2NhcmQ9JyArIGVuY29kZVVSSUNvbXBvbmVudChjYXJkKSArICcmdG9wX249JyArIHRvcE47CiAgICAgICAgICAgIGlmIChhY3RpdmVDb2xvcnMubGVuZ3RoID4gMCkgewogICAgICAgICAgICAgICAgdXJsICs9ICcmY29sb3JzPScgKyBhY3RpdmVDb2xvcnMuam9pbignJyk7CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKHVybCk7CiAgICAgICAgICAgIGlmICghcmVzLm9rKSB7CiAgICAgICAgICAgICAgICBjb25zdCBlcnIgPSBhd2FpdCByZXMuanNvbigpLmNhdGNoKCgpID0+ICh7IGRldGFpbDogJ1NlYXJjaCBmYWlsZWQnIH0pKTsKICAgICAgICAgICAgICAgIHRocm93IG5ldyBFcnJvcihlcnIuZGV0YWlsIHx8ICdTZWFyY2ggZmFpbGVkJyk7CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGNvbnN0IGRhdGEgPSBhd2FpdCByZXMuanNvbigpOwogICAgICAgICAgICBjb25zdCByZXN1bHRzID0gZGF0YS5yZXN1bHRzIHx8IFtdOwoKICAgICAgICAgICAgaWYgKHJlc3VsdHMubGVuZ3RoID09PSAwKSB7CiAgICAgICAgICAgICAgICBjYXJkc2xpa2VSZXN1bHRzLmlubmVySFRNTCA9ICc8ZGl2IGNsYXNzPSJjb2FjaC1jYXJkc2xpa2UtZW1wdHkiPk5vIHNpbWlsYXIgY2FyZHMgZm91bmQ8L2Rpdj4nOwogICAgICAgICAgICAgICAgcmV0dXJuOwogICAgICAgICAgICB9CgogICAgICAgICAgICBjYXJkc2xpa2VSZXN1bHRzLmlubmVySFRNTCA9ICcnOwogICAgICAgICAgICBmb3IgKGNvbnN0IGNhcmQgb2YgcmVzdWx0cykgewogICAgICAgICAgICAgICAgY29uc3QgZGl2ID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2Jyk7CiAgICAgICAgICAgICAgICBkaXYuY2xhc3NOYW1lID0gJ2NvYWNoLWNhcmRzbGlrZS1jYXJkJzsKCiAgICAgICAgICAgICAgICBjb25zdCBzaW1QY3QgPSBNYXRoLnJvdW5kKGNhcmQuc2ltaWxhcml0eSAqIDEwMCk7CiAgICAgICAgICAgICAgICBjb25zdCBvd25lZEJhZGdlID0gY2FyZC5vd25lZF9xdHkgPiAwCiAgICAgICAgICAgICAgICAgICAgPyAnPHNwYW4gY2xhc3M9ImNvYWNoLWNsLW93bmVkIj4nICsgY2FyZC5vd25lZF9xdHkgKyAnIG93bmVkPC9zcGFuPicKICAgICAgICAgICAgICAgICAgICA6ICc8c3BhbiBjbGFzcz0iY29hY2gtY2wtdW5vd25lZCI+bm90IG93bmVkPC9zcGFuPic7CiAgICAgICAgICAgICAgICBjb25zdCBwcmljZSA9IGNhcmQudGNnX3ByaWNlID8gJyQnICsgTnVtYmVyKGNhcmQudGNnX3ByaWNlKS50b0ZpeGVkKDIpIDogJyc7CgogICAgICAgICAgICAgICAgZGl2LmlubmVySFRNTCA9CiAgICAgICAgICAgICAgICAgICAgJzxkaXYgY2xhc3M9ImNvYWNoLWNsLXRvcCI+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICc8c3BhbiBjbGFzcz0iY29hY2gtY2wtbmFtZSI+JyArIGVzY0h0bWwoY2FyZC5uYW1lKSArICc8L3NwYW4+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICc8c3BhbiBjbGFzcz0iY29hY2gtY2wtc2ltIj4nICsgc2ltUGN0ICsgJyU8L3NwYW4+JyArCiAgICAgICAgICAgICAgICAgICAgJzwvZGl2PicgKwogICAgICAgICAgICAgICAgICAgICc8ZGl2IGNsYXNzPSJjb2FjaC1jbC1tZXRhIj4nICsKICAgICAgICAgICAgICAgICAgICAgICAgJzxzcGFuIGNsYXNzPSJjb2FjaC1jbC10eXBlIj4nICsgZXNjSHRtbChjYXJkLnR5cGVzIHx8ICcnKSArICc8L3NwYW4+JyArCiAgICAgICAgICAgICAgICAgICAgICAgIChjYXJkLm1hbmFfY29zdCA/ICc8c3BhbiBjbGFzcz0iY29hY2gtY2wtbWFuYSI+JyArIGVzY0h0bWwoY2FyZC5tYW5hX2Nvc3QpICsgJzwvc3Bhbj4nIDogJycpICsKICAgICAgICAgICAgICAgICAgICAgICAgb3duZWRCYWRnZSArCiAgICAgICAgICAgICAg' +
'ICAgICAgICAgIChwcmljZSA/ICc8c3BhbiBjbGFzcz0iY29hY2gtY2wtcHJpY2UiPicgKyBwcmljZSArICc8L3NwYW4+JyA6ICcnKSArCiAgICAgICAgICAgICAgICAgICAgJzwvZGl2PicgKwogICAgICAgICAgICAgICAgICAgIChjYXJkLnRleHQgPyAnPGRpdiBjbGFzcz0iY29hY2gtY2wtdGV4dCI+JyArIGVzY0h0bWwoY2FyZC50ZXh0KSArICc8L2Rpdj4nIDogJycpICsKICAgICAgICAgICAgICAgICAgICAnPGRpdiBjbGFzcz0iY29hY2gtY2wtYWN0aW9ucyI+JyArCiAgICAgICAgICAgICAgICAgICAgICAgICc8YnV0dG9uIGNsYXNzPSJidG4gYnRuLWdob3N0IGJ0bi14cyBjb2FjaC1jbC1jaGF0LWJ0biI+QWRkIHRvIENoYXQ8L2J1dHRvbj4nICsKICAgICAgICAgICAgICAgICAgICAnPC9kaXY+JzsKCiAgICAgICAgICAgICAgICBkaXYucXVlcnlTZWxlY3RvcignLmNvYWNoLWNsLWNoYXQtYnRuJykuYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCAoKSA9PiB7CiAgICAgICAgICAgICAgICAgICAgY2hhdElucHV0LnZhbHVlICs9IChjaGF0SW5wdXQudmFsdWUgPyAnICcgOiAnJykgKyBjYXJkLm5hbWU7CiAgICAgICAgICAgICAgICAgICAgY2hhdElucHV0LmZvY3VzKCk7CiAgICAgICAgICAgICAgICAgICAgc2hvd1RvYXN0KGNhcmQubmFtZSArICcgYWRkZWQgdG8gY2hhdCBpbnB1dCcsICdzdWNjZXNzJyk7CiAgICAgICAgICAgICAgICB9KTsKCiAgICAgICAgICAgICAgICBjYXJkc2xpa2VSZXN1bHRzLmFwcGVuZENoaWxkKGRpdik7CiAgICAgICAgICAgIH0KICAgICAgICB9IGNhdGNoIChlKSB7CiAgICAgICAgICAgIGNhcmRzbGlrZVJlc3VsdHMuaW5uZXJIVE1MID0gJzxkaXYgY2xhc3M9ImNvYWNoLWNhcmRzbGlrZS1lbXB0eSI+RXJyb3I6ICcgKyBlc2NIdG1sKGUubWVzc2FnZSkgKyAnPC9kaXY+JzsKICAgICAgICB9CiAgICB9CgogICAgLy8gQ29sb3IgcGlwIHRvZ2dsZQogICAgZG9jdW1lbnQucXVlcnlTZWxlY3RvckFsbCgnLmNvYWNoLWNhcmRzbGlrZS1jb2xvcnMgLmNvYWNoLWNvbG9yLXBpcCcpLmZvckVhY2goYnRuID0+IHsKICAgICAgICBidG4uYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCAoKSA9PiBidG4uY2xhc3NMaXN0LnRvZ2dsZSgnYWN0aXZlJykpOwogICAgfSk7CgogICAgLy8g4pSA4pSAIFRvYXN0IOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgAoKICAgIGZ1bmN0aW9uIHNob3dUb2FzdChtZXNzYWdlLCB0eXBlKSB7CiAgICAgICAgY29uc3QgY29udGFpbmVyID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2NvYWNoLXRvYXN0LWNvbnRhaW5lcicpOwogICAgICAgIGNvbnN0IHRvYXN0ID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2Jyk7CiAgICAgICAgdG9hc3QuY2xhc3NOYW1lID0gJ2NvYWNoLXRvYXN0IGNvYWNoLXRvYXN0LScgKyAodHlwZSB8fCAnaW5mbycpOwogICAgICAgIHRvYXN0LnRleHRDb250ZW50ID0gbWVzc2FnZTsKICAgICAgICBjb250YWluZXIuYXBwZW5kQ2hpbGQodG9hc3QpOwoKICAgICAgICBzZXRUaW1lb3V0KCgpID0+IHRvYXN0LmNsYXNzTGlzdC5hZGQoJ2NvYWNoLXRvYXN0LXNob3cnKSwgMTApOwogICAgICAgIHNldFRpbWVvdXQoKCkgPT4gewogICAgICAgICAgICB0b2FzdC5jbGFzc0xpc3QucmVtb3ZlKCdjb2FjaC10b2FzdC1zaG93Jyk7CiAgICAgICAgICAgIHNldFRpbWVvdXQoKCkgPT4gdG9hc3QucmVtb3ZlKCksIDMwMCk7CiAgICAgICAgfSwgNDAwMCk7CiAgICB9CgogICAgLy8g4pSA4pSAIEhlbHBlcnMg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACgogICAgZnVuY3Rpb24gYWRkTWV0YUNoaXAobGFiZWwsIHZhbHVlKSB7CiAgICAgICAgY29uc3QgY2hpcCA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoJ3NwYW4nKTsKICAgICAgICBjaGlwLmNsYXNzTmFtZSA9ICdjb2FjaC1tZXRhLWNoaXAnOwogICAgICAgIGNoaXAuaW5uZXJIVE1MID0gJzxzdHJvbmc+JyArIGVzY0h0bWwobGFiZWwpICsgJzo8L3N0cm9uZz4gJyArIGVzY0h0bWwodmFsdWUpOwogICAgICAgIG1ldGFSb3cuYXBw' +
'ZW5kQ2hpbGQoY2hpcCk7CiAgICB9CgogICAgZnVuY3Rpb24gZm9ybWF0SW1wYWN0KHNjb3JlKSB7CiAgICAgICAgaWYgKHNjb3JlID09PSB1bmRlZmluZWQgfHwgc2NvcmUgPT09IG51bGwpIHsKICAgICAgICAgICAgcmV0dXJuICc8c3BhbiBjbGFzcz0iY29hY2gtaW1wYWN0IGNvYWNoLWltcGFjdC1uZXV0cmFsIj5cdTIwMTQ8L3NwYW4+JzsKICAgICAgICB9CiAgICAgICAgY29uc3QgZml4ZWQgPSBzY29yZS50b0ZpeGVkKDMpOwogICAgICAgIGlmIChzY29yZSA8IC0wLjAxKSB7CiAgICAgICAgICAgIHJldHVybiAnPHNwYW4gY2xhc3M9ImNvYWNoLWltcGFjdCBjb2FjaC1pbXBhY3QtbmVnYXRpdmUiPicgKyBmaXhlZCArICc8L3NwYW4+JzsKICAgICAgICB9IGVsc2UgaWYgKHNjb3JlID4gMC4wMSkgewogICAgICAgICAgICByZXR1cm4gJzxzcGFuIGNsYXNzPSJjb2FjaC1pbXBhY3QgY29hY2gtaW1wYWN0LXBvc2l0aXZlIj4rJyArIGZpeGVkICsgJzwvc3Bhbj4nOwogICAgICAgIH0gZWxzZSB7CiAgICAgICAgICAgIHJldHVybiAnPHNwYW4gY2xhc3M9ImNvYWNoLWltcGFjdCBjb2FjaC1pbXBhY3QtbmV1dHJhbCI+JyArIGZpeGVkICsgJzwvc3Bhbj4nOwogICAgICAgIH0KICAgIH0KCiAgICBmdW5jdGlvbiBmb3JtYXRUaW1lKGlzb1N0cikgewogICAgICAgIHRyeSB7CiAgICAgICAgICAgIGNvbnN0IGQgPSBuZXcgRGF0ZShpc29TdHIpOwogICAgICAgICAgICByZXR1cm4gZC50b0xvY2FsZURhdGVTdHJpbmcoKSArICcgJyArIGQudG9Mb2NhbGVUaW1lU3RyaW5nKFtdLCB7IGhvdXI6ICcyLWRpZ2l0JywgbWludXRlOiAnMi1kaWdpdCcgfSk7CiAgICAgICAgfSBjYXRjaCB7CiAgICAgICAgICAgIHJldHVybiBpc29TdHI7CiAgICAgICAgfQogICAgfQoKICAgIGZ1bmN0aW9uIGVzY0h0bWwoc3RyKSB7CiAgICAgICAgaWYgKCFzdHIpIHJldHVybiAnJzsKICAgICAgICByZXR1cm4gU3RyaW5nKHN0cikKICAgICAgICAgICAgLnJlcGxhY2UoLyYvZywgJyZhbXA7JykKICAgICAgICAgICAgLnJlcGxhY2UoLzwvZywgJyZsdDsnKQogICAgICAgICAgICAucmVwbGFjZSgvPi9nLCAnJmd0OycpCiAgICAgICAgICAgIC5yZXBsYWNlKC8iL2csICcmcXVvdDsnKTsKICAgIH0KCn0pKCk7Cg=='
Install-B64 -R 'ui\coach.js' -B $b
# ui/collection.css (56,542 bytes)
+7479 -154
View File
File diff suppressed because it is too large. Load diff
-8872
View File
File diff suppressed because it is too large. Load diff
-4487
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -240,12 +240,12 @@ def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
dist[action.value] = count
total = len(labels)
log.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
log.info("─" * 43)
print(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
print("─" * 43)
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
pct = (count / total * 100) if total > 0 else 0
log.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
log.info(f"{'─' * 43}")
log.info(f" {'TOTAL':<23} {total:>8}")
print(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
print(f"{'─' * 43}")
print(f" {'TOTAL':<23} {total:>8}")
return dist
+8 -8
View File
@@ -154,17 +154,17 @@ def build_dataset(
# Print game outcome distribution
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
log.info(f"\n{'Outcome':<25} {'Count':>8}")
log.info("─" * 35)
print(f"\n{'Outcome':<25} {'Count':>8}")
print("─" * 35)
for o, c in sorted(zip(unique_outcomes, outcome_counts), key=lambda x: -x[1]):
log.info(f" {str(o):<23} {c:>8}")
print(f" {str(o):<23} {c:>8}")
# Print playstyle distribution
unique_styles, style_counts = np.unique(playstyles, return_counts=True)
log.info(f"\n{'Playstyle':<25} {'Count':>8}")
log.info("─" * 35)
print(f"\n{'Playstyle':<25} {'Count':>8}")
print("─" * 35)
for s, c in sorted(zip(unique_styles, style_counts), key=lambda x: -x[1]):
log.info(f" {str(s):<23} {c:>8}")
print(f" {str(s):<23} {c:>8}")
return {
"states": states,
@@ -288,7 +288,7 @@ def main():
max_samples=args.max_samples,
)
if not dataset:
log.info("\nNo data to save. Run simulations with --ml-log first.")
print("\nNo data to save. Run simulations with --ml-log first.")
sys.exit(1)
if args.no_split:
@@ -300,7 +300,7 @@ def main():
save_dataset(val, f"{base}-val.npz")
save_dataset(test, f"{base}-test.npz")
log.info("\nDataset build complete.")
print("\nDataset build complete.")
if __name__ == "__main__":
-344
View File
@@ -1,344 +0,0 @@
"""
update_weights.py
=================
Outcome-weighted reinforcement learning for Commander AI card weights.
Reads all decisions_*.jsonl files produced by DeepSeekBrain.flush_log(),
applies a gradient-free reward nudge to each weight key based on the
normalised reward signal (-1.0 to +1.0) stamped on every decision entry,
then saves the updated weights to learned_weights.json via save_weights().
Usage
-----
# Basic run (reads logs/decisions/, updates learned_weights.json)
python -m ml.scripts.update_weights
# Custom paths
python -m ml.scripts.update_weights \\
--log-dir logs/decisions \\
--weights-out src/commander_ai_lab/sim/learned_weights.json \\
--lr 0.05 \\
--min-games 10
# Dry-run (print proposed changes, don't write)
python -m ml.scripts.update_weights --dry-run
# Reset to defaults then exit
python -m ml.scripts.update_weights --reset
Algorithm
---------
For each decision entry that has a non-null game_result:
delta = learning_rate * reward * action_weight_for(action)
Each weight key is nudged by the mean delta across all decisions that
touched that key. Keys with no decisions are left unchanged.
All values are clamped to [-20.0, 20.0] by save_weights().
Reward is the normalised life-delta from flush_log():
reward = (my_final_life - avg_opp_final_life) / 40.0 in [-1.0, 1.0]
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
from collections import defaultdict
from pathlib import Path
from typing import Iterator
# ---------------------------------------------------------------------------
# Allow running as a script without installing the package
# ---------------------------------------------------------------------------
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT / "src") not in sys.path:
sys.path.insert(0, str(_REPO_ROOT / "src"))
from commander_ai_lab.sim.rules import (
AI_DEFAULT_WEIGHTS,
load_weights,
reset_weights,
save_weights,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("update_weights")
# ---------------------------------------------------------------------------
# Action → weight key mapping
# Maps each VALID_ACTION to the weight(s) it most directly influences.
# A single action may touch multiple keys (listed as a tuple).
# ---------------------------------------------------------------------------
ACTION_WEIGHT_MAP: dict[str, tuple[str, ...]] = {
"play_land": (), # no weight key — always correct
"cast_ramp": ("spell_ramp",),
"cast_removal": ("spell_destroy",),
"cast_board_wipe": ("spell_counter",), # reuse counter as board-control proxy
"cast_creature": ("card_ptBonus",),
"cast_spell": ("spell_enchantment", "spell_draw"),
"attack_all": ("kw_haste", "kw_flying"), # aggression / evasion proxy
"attack_safe": ("kw_flying", "kw_trample"),
"hold": (), # no weight key — passing is neutral
}
# ---------------------------------------------------------------------------
# JSONL helpers
# ---------------------------------------------------------------------------
def _iter_log_entries(log_dir: str) -> Iterator[dict]:
"""Yield every entry from all decisions_*.jsonl files in log_dir."""
log_path = Path(log_dir)
if not log_path.exists():
logger.warning("Log directory not found: %s", log_dir)
return
files = sorted(log_path.glob("decisions_*.jsonl"))
if not files:
logger.warning("No decisions_*.jsonl files found in %s", log_dir)
return
logger.info("Found %d log file(s) in %s", len(files), log_dir)
for fpath in files:
with open(fpath, encoding="utf-8") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError as exc:
logger.warning("Skipping malformed line %d in %s: %s", lineno, fpath.name, exc)
# ---------------------------------------------------------------------------
# Core learning loop
# ---------------------------------------------------------------------------
def compute_weight_updates(
log_dir: str,
current_weights: dict[str, float],
learning_rate: float = 0.05,
min_games: int = 1,
) -> tuple[dict[str, float], dict]:
"""
Read decision logs and compute updated weights.
Returns
-------
updated_weights : dict[str, float]
New weight dict after applying mean reward nudges.
stats : dict
Diagnostic info (games seen, decisions processed, keys changed, etc.)
"""
# Accumulators: key → list of (reward * lr) deltas
key_deltas: dict[str, list[float]] = defaultdict(list)
total_entries = 0
labelled_entries = 0 # entries with non-null game_result
game_ids_seen: set[str] = set()
action_counts: dict[str, int] = defaultdict(int)
for entry in _iter_log_entries(log_dir):
total_entries += 1
game_result = entry.get("game_result")
if not game_result:
continue # no outcome stamped — skip (pre-flush or incomplete game)
labelled_entries += 1
game_ids_seen.add(entry.get("game_id", "unknown"))
reward: float = float(game_result.get("reward", 0.0))
action: str = entry.get("decision", {}).get("action", "hold")
action_counts[action] += 1
weight_keys = ACTION_WEIGHT_MAP.get(action, ())
for key in weight_keys:
if key in current_weights:
key_deltas[key].append(learning_rate * reward)
num_games = len(game_ids_seen)
logger.info(
"Processed %d entries | %d labelled | %d unique games",
total_entries, labelled_entries, num_games,
)
if num_games < min_games:
logger.warning(
"Only %d game(s) found — minimum is %d. Skipping update.",
num_games, min_games,
)
return dict(current_weights), {
"skipped": True,
"reason": f"need {min_games} games, have {num_games}",
"total_entries": total_entries,
"labelled_entries": labelled_entries,
"games": num_games,
}
# Apply mean delta per key
updated = dict(current_weights)
changed_keys: dict[str, tuple[float, float]] = {} # key → (old, new)
for key, deltas in key_deltas.items():
if not deltas:
continue
mean_delta = sum(deltas) / len(deltas)
old_val = updated[key]
new_val = round(old_val + mean_delta, 6)
updated[key] = new_val
if abs(new_val - old_val) >= 0.0001:
changed_keys[key] = (old_val, new_val)
stats = {
"skipped": False,
"total_entries": total_entries,
"labelled_entries": labelled_entries,
"games": num_games,
"keys_changed": len(changed_keys),
"changes": changed_keys,
"action_counts": dict(action_counts),
}
return updated, stats
def print_diff(stats: dict, current: dict, updated: dict) -> None:
"""Pretty-print weight changes to stdout."""
changes = stats.get("changes", {})
if not changes:
print(" (no weight changes above threshold)")
return
col_w = max(len(k) for k in changes) + 2
print(f" {'Key':<{col_w}} {'Before':>10} {'After':>10} {'Delta':>10}")
print(" " + "-" * (col_w + 36))
for key, (old, new) in sorted(changes.items(), key=lambda x: abs(x[1][1] - x[1][0]), reverse=True):
delta = new - old
arrow = "▲" if delta > 0 else "▼"
print(f" {key:<{col_w}} {old:>10.4f} {new:>10.4f} {arrow} {abs(delta):>8.4f}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _default_log_dir() -> str:
return str(_REPO_ROOT / "logs" / "decisions")
def _default_weights_out() -> str:
return str(_REPO_ROOT / "src" / "commander_ai_lab" / "sim" / "learned_weights.json")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Update AI card weights from simulation decision logs."
)
parser.add_argument(
"--log-dir",
default=_default_log_dir(),
help="Directory containing decisions_*.jsonl files (default: logs/decisions/)",
)
parser.add_argument(
"--weights-out",
default=_default_weights_out(),
help="Path to write learned_weights.json (default: src/.../sim/learned_weights.json)",
)
parser.add_argument(
"--lr",
type=float,
default=0.05,
help="Learning rate — how much each game nudges a weight (default: 0.05)",
)
parser.add_argument(
"--min-games",
type=int,
default=10,
help="Minimum number of completed games required before updating (default: 10)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print proposed changes without writing learned_weights.json",
)
parser.add_argument(
"--reset",
action="store_true",
help="Delete learned_weights.json and restore AI_DEFAULT_WEIGHTS, then exit",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Show action distribution and per-key delta counts",
)
args = parser.parse_args(argv)
# ── Reset mode ──────────────────────────────────────────────
if args.reset:
reset_weights(args.weights_out)
print(f"Reset: deleted {args.weights_out} (if it existed). Defaults restored.")
return 0
# ── Load current weights ─────────────────────────────────────
current = load_weights(args.weights_out)
logger.info("Loaded %d weight keys from %s", len(current), args.weights_out)
# ── Compute updates ──────────────────────────────────────────
updated, stats = compute_weight_updates(
log_dir=args.log_dir,
current_weights=current,
learning_rate=args.lr,
min_games=args.min_games,
)
# ── Report ───────────────────────────────────────────────────
print()
print("=" * 60)
print(" Commander AI — Weight Update Report")
print("=" * 60)
print(f" Log dir : {args.log_dir}")
print(f" Weights out: {args.weights_out}")
print(f" Games : {stats['games']}")
print(f" Entries : {stats['total_entries']} ({stats['labelled_entries']} labelled)")
print(f" LR : {args.lr}")
print()
if stats.get("skipped"):
print(f" SKIPPED — {stats['reason']}")
print()
return 0
print(f" Keys changed: {stats['keys_changed']}")
print()
print_diff(stats, current, updated)
print()
if args.verbose:
print(" Action distribution:")
for action, count in sorted(stats["action_counts"].items(), key=lambda x: -x[1]):
print(f" {action:<20} {count:>6} decisions")
print()
# ── Write ────────────────────────────────────────────────────
if args.dry_run:
print(" DRY RUN — no file written.")
else:
out_path = save_weights(updated, args.weights_out)
print(f" Saved updated weights to: {out_path}")
print("=" * 60)
print()
return 0
if __name__ == "__main__":
sys.exit(main())
+2 -6
View File
@@ -16,10 +16,6 @@ The network is designed for:
import os
import sys
from pathlib import Path
import logging
log = logging.getLogger("commander_ai_lab.ml.training")
# Conditional torch import — graceful failure if not installed
try:
@@ -29,7 +25,7 @@ try:
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
log.warning("PyTorch not installed. Install with: pip install torch")
print("[ML] PyTorch not installed. Install with: pip install torch")
import numpy as np
@@ -208,7 +204,7 @@ def save_checkpoint(
"dropout": TRAINING_CONFIG.dropout,
},
}, path)
log.info(f"Checkpoint saved: {path}")
print(f"[ML] Checkpoint saved: {path}")
def load_checkpoint(path: str, device: str = "cpu"):
+27 -26
View File
@@ -49,7 +49,8 @@ from ml.training.policy_network import (
PolicyNetwork, save_checkpoint, load_checkpoint,
)
logger = logging.getLogger("commander_ai_lab.ml.training")
logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
logger = logging.getLogger("ml.trainer")
# ══════════════════════════════════════════════════════════
@@ -501,40 +502,40 @@ def evaluate_model(
def print_evaluation(results: Dict):
"""Pretty-print evaluation results."""
logger.info("")
logger.info("=" * 65)
logger.info(" Test Set Evaluation")
logger.info("=" * 65)
logger.info(f" Overall Accuracy: {results['overall_accuracy']:.1%}")
logger.info(f" Total Samples: {results['total_samples']:,}")
logger.info("")
logger.info(f" {'Action':<20} {'Support':>8} {'Prec':>7} {'Recall':>7} {'F1':>7}")
logger.info(" " + "-" * 55)
print()
print("=" * 65)
print(" Test Set Evaluation")
print("=" * 65)
print(f" Overall Accuracy: {results['overall_accuracy']:.1%}")
print(f" Total Samples: {results['total_samples']:,}")
print()
print(f" {'Action':<20} {'Support':>8} {'Prec':>7} {'Recall':>7} {'F1':>7}")
print(" " + "-" * 55)
for action_name, m in results["per_class"].items():
logger.info(
print(
f" {action_name:<20} {m['support']:>8} "
f"{m['precision']:>7.3f} {m['recall']:>7.3f} {m['f1']:>7.3f}"
)
logger.info(" " + "-" * 55)
print(" " + "-" * 55)
# Confusion matrix
logger.info("")
logger.info(" Confusion Matrix (rows=true, cols=predicted):")
print()
print(" Confusion Matrix (rows=true, cols=predicted):")
header = " " + " " * 18
for c in range(NUM_ACTIONS):
short_name = IDX_TO_ACTION[c].value[:6]
header += f" {short_name:>6}"
logger.info(header)
print(header)
for r in range(NUM_ACTIONS):
row_name = IDX_TO_ACTION[r].value[:16]
row_str = f" {row_name:<18}"
for c in range(NUM_ACTIONS):
row_str += f" {results['confusion_matrix'][r][c]:>6}"
logger.info(row_str)
logger.info("")
print(row_str)
print()
# ══════════════════════════════════════════════════════════
@@ -604,8 +605,8 @@ def main():
args = parser.parse_args()
if not TORCH_AVAILABLE:
logger.info("PyTorch not installed. Install with:")
logger.info(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121")
print("PyTorch not installed. Install with:")
print(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121")
sys.exit(1)
device = args.device or get_best_device()
@@ -620,13 +621,13 @@ def main():
if args.eval_only:
# Evaluation only
if not os.path.exists(test_path):
logger.info(f"Test data not found: {test_path}")
logger.info("Run: python -m ml.scripts.ml_cli build")
print(f"Test data not found: {test_path}")
print("Run: python -m ml.scripts.ml_cli build")
sys.exit(1)
ckpt_path = args.checkpoint or os.path.join(ckpt_dir, "best_policy.pt")
if not os.path.exists(ckpt_path):
logger.info(f"Checkpoint not found: {ckpt_path}")
print(f"Checkpoint not found: {ckpt_path}")
sys.exit(1)
test_states, test_labels = load_npz_split(test_path)
@@ -640,15 +641,15 @@ def main():
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w") as f:
json.dump(results, f, indent=2)
logger.info(f" Results saved: {eval_path}")
print(f" Results saved: {eval_path}")
return
# Training mode
for path, label in [(train_path, "train"), (val_path, "val")]:
if not os.path.exists(path):
logger.info(f"{label} data not found: {path}")
logger.info("Build the dataset first:")
logger.info(" python -m ml.scripts.ml_cli build --results-dir results/")
print(f"{label} data not found: {path}")
print("Build the dataset first:")
print(" python -m ml.scripts.ml_cli build --results-dir results/")
sys.exit(1)
train_states, train_labels = load_npz_split(train_path)
+79 -121
View File
@@ -22,15 +22,11 @@ Configuration:
import argparse
import json
import logging
import logging.handlers
import os
import sys
import time
import urllib.request
import urllib.error
from datetime import datetime, timedelta
from pathlib import Path
# ── Default Config ──────────────────────────────────────────────
@@ -55,48 +51,10 @@ DEFAULT_CONFIG = {
}
# ── Logging ──────────────────────────────────────────────────
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
def setup_overnight_logging() -> logging.Logger:
"""Configure logging for the overnight runner.
Outputs to both console and a rotating log file so that
unattended runs always have a persistent record.
"""
_LOG_DIR.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("commander_ai_lab.overnight")
logger.setLevel(logging.INFO)
if logger.handlers:
return logger
fmt = logging.Formatter(
"%(asctime)s %(levelname)-5s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(fmt)
logger.addHandler(console)
file_handler = logging.handlers.RotatingFileHandler(
_LOG_DIR / "overnight-run.log",
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding="utf-8",
)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(fmt)
logger.addHandler(file_handler)
return logger
log = setup_overnight_logging()
def log(msg):
ts = datetime.now().strftime("%H:%M:%S")
print(f"[{ts}] {msg}")
sys.stdout.flush()
def api_get(base_url, path):
@@ -171,8 +129,8 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
while True:
# Hard deadline check — stop waiting if we've run out of time
if deadline and time.time() >= deadline:
log.info(f" Time limit reached while batch {batch_id} was running.")
log.info(f" The batch will continue on the server but we're moving on.")
log(f" Time limit reached while batch {batch_id} was running.")
log(f" The batch will continue on the server but we're moving on.")
return {"completed": 0, "timed_out": True}
try:
@@ -186,12 +144,12 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
elapsed = status.get("elapsedMs", 0)
if error:
log.error(f" Batch {batch_id} ERROR: {error}")
log(f" Batch {batch_id} ERROR: {error}")
return None
if not running and completed > 0:
elapsed_s = elapsed / 1000 if elapsed else 0
log.info(f" Batch {batch_id} complete: {completed} games in {elapsed_s:.0f}s")
log(f" Batch {batch_id} complete: {completed} games in {elapsed_s:.0f}s")
try:
result = api_get(base_url, f"/api/lab/result?batchId={batch_id}")
return result
@@ -202,15 +160,15 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
if not running and completed == 0:
stall_checks += 1
if stall_checks > 20: # 5 min with no progress
log.info(f" Batch {batch_id} never started. Aborting.")
log(f" Batch {batch_id} never started. Aborting.")
return None
# Progress log with ETA
if deadline:
mins_left = (deadline - time.time()) / 60
log.info(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s) — {mins_left:.0f}min left in run")
log(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s) — {mins_left:.0f}min left in run")
else:
log.info(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s)")
log(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s)")
# Track stall
if completed == last_completed:
@@ -222,9 +180,9 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
except Exception as e:
consecutive_errors += 1
if consecutive_errors > 10:
log.info(f" Lost connection to server after 10 retries. Aborting batch.")
log(f" Lost connection to server after 10 retries. Aborting batch.")
return None
log.info(f" Connection error (retry {consecutive_errors}/10): {e}")
log(f" Connection error (retry {consecutive_errors}/10): {e}")
time.sleep(poll_interval)
@@ -256,26 +214,26 @@ def wait_for_training(base_url):
error = status.get("error")
if error:
log.error(f" Training ERROR: {error}")
log(f" Training ERROR: {error}")
return False
if not running and phase in ("done", "idle") and epoch > 0:
result = status.get("result", {})
log.info(f" Training complete!")
log(f" Training complete!")
if result:
log.info(f" Final accuracy: {result.get('best_val_accuracy', '?')}")
log.info(f" Model saved: {result.get('model_path', '?')}")
log(f" Final accuracy: {result.get('best_val_accuracy', '?')}")
log(f" Model saved: {result.get('model_path', '?')}")
return True
if phase == "training" and total > 0:
metrics = status.get("metrics", {})
val_acc = metrics.get("val_accuracy", "?") if metrics else "?"
log.info(f" Training: epoch {epoch}/{total} — {message} (val_acc: {val_acc})")
log(f" Training: epoch {epoch}/{total} — {message} (val_acc: {val_acc})")
else:
log.info(f" [{phase}] {message}")
log(f" [{phase}] {message}")
except Exception as e:
log.info(f" Connection error: {e}")
log(f" Connection error: {e}")
time.sleep(poll_interval)
@@ -295,46 +253,46 @@ def run_overnight(cfg):
"""Main overnight pipeline."""
base_url = cfg["lab_url"]
log.info("=" * 60)
log.info(" Commander AI Lab — Overnight AFK Runner")
log.info("=" * 60)
log("=" * 60)
log(" Commander AI Lab — Overnight AFK Runner")
log("=" * 60)
# ── Verify server ────────────────────────────────────────
log.info("Checking lab server...")
log("Checking lab server...")
if not check_server(base_url):
log.error(f"ERROR: Cannot reach lab server at {base_url}")
log.info("Start the lab first: start-lab.bat")
log(f"ERROR: Cannot reach lab server at {base_url}")
log("Start the lab first: start-lab.bat")
return False
log.info(f"Server OK at {base_url}")
log(f"Server OK at {base_url}")
# ── Get decks ─────────────────────────────────────────────
available_decks = get_available_decks(base_url)
log.info(f"Found {len(available_decks)} decks: {', '.join(available_decks[:5])}{'...' if len(available_decks) > 5 else ''}")
log(f"Found {len(available_decks)} decks: {', '.join(available_decks[:5])}{'...' if len(available_decks) > 5 else ''}")
engine = cfg["engine"]
if engine == "deepseek":
decks = cfg["ds_decks"] if cfg["ds_decks"] else available_decks
if not decks:
log.error("ERROR: No decks available. Create decks in the Deck Builder first.")
log("ERROR: No decks available. Create decks in the Deck Builder first.")
return False
log.info(f"Using DeepSeek engine with {len(decks)} deck(s)")
log(f"Using DeepSeek engine with {len(decks)} deck(s)")
else:
decks = cfg["java_decks"]
if len(decks) != 3:
log.error("ERROR: Java engine requires exactly 3 deck names.")
log.info("Set java_decks in the config or use --decks deck1 deck2 deck3")
log("ERROR: Java engine requires exactly 3 deck names.")
log("Set java_decks in the config or use --decks deck1 deck2 deck3")
return False
log.info(f"Using Java/Forge engine with decks: {decks}")
log(f"Using Java/Forge engine with decks: {decks}")
# ── ML logging ────────────────────────────────────────────
log.info("Enabling ML logging...")
log("Enabling ML logging...")
try:
api_post(base_url, "/api/ml/toggle?enable=true", {})
log.info("ML logging enabled")
log("ML logging enabled")
except Exception as e:
log.warning(f"WARNING: Could not enable ML logging: {e}")
log.info("(DeepSeek batches always log ML data regardless)")
log(f"WARNING: Could not enable ML logging: {e}")
log("(DeepSeek batches always log ML data regardless)")
# Try alternate form
try:
api_get(base_url, "/api/ml/toggle?enable=true")
@@ -343,7 +301,7 @@ def run_overnight(cfg):
# ── Initial data stats ────────────────────────────────────
decisions_before, files_before = get_ml_data_summary(base_url)
log.info(f"Current ML data: {decisions_before} decisions in {files_before} files")
log(f"Current ML data: {decisions_before} decisions in {files_before} files")
# ── Run batches ───────────────────────────────────────────
games_per_batch = cfg["games_per_batch"]
@@ -357,32 +315,32 @@ def run_overnight(cfg):
total_games = 0
total_decisions = 0
log.info("")
log.info(f"Starting batch loop:")
log("")
log(f"Starting batch loop:")
if num_batches > 0:
log.info(f" Mode: {num_batches} batches × {games_per_batch} games")
log(f" Mode: {num_batches} batches × {games_per_batch} games")
else:
log.info(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
log.info(f" Engine: {engine}")
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
log.info("")
log(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
log(f" Engine: {engine}")
log(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
log("")
while True:
# Check stopping conditions
if num_batches > 0 and batch_num >= num_batches:
log.info(f"Reached target of {num_batches} batches. Stopping.")
log(f"Reached target of {num_batches} batches. Stopping.")
break
elapsed = time.time() - start_time
remaining = deadline - time.time()
if remaining < 120: # need at least 2 min for a batch
log.info(f"Time limit approaching ({remaining:.0f}s left). Stopping sim loop.")
log(f"Time limit approaching ({remaining:.0f}s left). Stopping sim loop.")
break
batch_num += 1
elapsed_str = str(timedelta(seconds=int(elapsed)))
remaining_str = str(timedelta(seconds=int(remaining)))
log.info(f"── Batch {batch_num} ── (elapsed: {elapsed_str}, remaining: {remaining_str})")
log(f"── Batch {batch_num} ── (elapsed: {elapsed_str}, remaining: {remaining_str})")
try:
if engine == "deepseek":
@@ -394,12 +352,12 @@ def run_overnight(cfg):
)
if not batch_id:
log.error(" ERROR: Failed to start batch (no batchId returned)")
log.info(" Waiting 60s before retry...")
log(" ERROR: Failed to start batch (no batchId returned)")
log(" Waiting 60s before retry...")
time.sleep(60)
continue
log.info(f" Started batch {batch_id}")
log(f" Started batch {batch_id}")
result = wait_for_batch(base_url, batch_id, engine, deadline=deadline)
if result:
@@ -408,19 +366,19 @@ def run_overnight(cfg):
for deck in result.get("decks", []):
wr = deck.get("winRate", "?")
name = deck.get("deckName", "?")
log.info(f" {name}: {wr}% WR")
log(f" {name}: {wr}% WR")
else:
log.info(" Batch failed, continuing to next...")
log(" Batch failed, continuing to next...")
except Exception as e:
log.error(f" ERROR starting batch: {e}")
log.info(" Waiting 60s before retry...")
log(f" ERROR starting batch: {e}")
log(" Waiting 60s before retry...")
time.sleep(60)
continue
# Pause between batches
if pause_sec > 0:
log.info(f" Cooling down {pause_sec}s...")
log(f" Cooling down {pause_sec}s...")
time.sleep(pause_sec)
# ── Summary ───────────────────────────────────────────────
@@ -429,26 +387,26 @@ def run_overnight(cfg):
new_decisions = decisions_after - decisions_before
new_files = files_after - files_before
log.info("")
log.info("=" * 60)
log.info(" BATCH SIMULATION COMPLETE")
log.info("=" * 60)
log.info(f" Batches run: {batch_num}")
log.info(f" Total games: ~{total_games}")
log.info(f" New ML decisions: {new_decisions} (in {new_files} new files)")
log.info(f" Total ML data: {decisions_after} decisions")
log.info(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
log.info("")
log("")
log("=" * 60)
log(" BATCH SIMULATION COMPLETE")
log("=" * 60)
log(f" Batches run: {batch_num}")
log(f" Total games: ~{total_games}")
log(f" New ML decisions: {new_decisions} (in {new_files} new files)")
log(f" Total ML data: {decisions_after} decisions")
log(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
log("")
# ── Auto-train ────────────────────────────────────────────
if cfg["auto_train"]:
if decisions_after < 100:
log.info("Skipping training — too few decisions (need at least 100)")
log("Skipping training — too few decisions (need at least 100)")
return True
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']}")
log("Starting ML training pipeline...")
log(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
try:
start_training(
@@ -458,21 +416,21 @@ def run_overnight(cfg):
batch_size=cfg["train_batch_size"],
patience=cfg["train_patience"],
)
log.info("Training started, monitoring progress...")
log("Training started, monitoring progress...")
success = wait_for_training(base_url)
if success:
log.info("")
log.info("=" * 60)
log.info(" TRAINING COMPLETE — model updated!")
log.info("=" * 60)
log("")
log("=" * 60)
log(" TRAINING COMPLETE — model updated!")
log("=" * 60)
else:
log.info("Training finished with errors. Check the Training tab for details.")
log("Training finished with errors. Check the Training tab for details.")
except Exception as e:
log.error(f"ERROR starting training: {e}")
log(f"ERROR starting training: {e}")
return False
log.info("")
log.info("Overnight run finished. You can close this window.")
log("")
log("Overnight run finished. You can close this window.")
return True
-24
View File
@@ -10,32 +10,8 @@ readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
dependencies = [
"fastapi>=0.100.0",
"uvicorn[standard]>=0.23.0",
"pydantic>=2.0.0",
"httpx>=0.24.0",
"websockets>=11.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-asyncio>=0.21",
"httpx>=0.24.0",
"ruff>=0.1.0",
]
[tool.setuptools.packages.find]
where = ["src"]
[project.scripts]
commander-sim = "commander_ai_lab.lab.experiments:_cli_main"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = [".", "src"]
[tool.ruff]
line-length = 120
target-version = "py310"
-24
View File
@@ -1,24 +0,0 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test* Bench*
python_functions = test_*
# Asyncio mode for pytest-asyncio
asyncio_mode = auto
# Markers
markers =
slow: marks tests as slow (deselect with -m "not slow")
integration: marks integration tests
benchmark: marks benchmark tests
unit: marks fast unit tests
# Output
addopts =
--tb=short
-q
# Coverage (used when running: pytest --cov)
# pip install pytest-cov
# pytest --cov=src/commander_ai_lab --cov-report=term-missing
-11
View File
@@ -1,11 +0,0 @@
# Commander AI Lab — Web server dependencies (Phase 5)
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
httpx>=0.27.0 # async Scryfall art proxy
websockets>=12.0 # used by uvicorn WebSocket transport
pydantic>=2.6.0
# Dev / test
pytest>=8.0
pytest-asyncio>=0.23
httpx>=0.27.0 # TestClient uses httpx transport
-9
View File
@@ -1,9 +0,0 @@
# Core runtime dependencies
# Install with: pip install -r requirements.txt
# Or for dev tools: pip install -e ".[dev]"
fastapi>=0.100.0
uvicorn[standard]>=0.23.0
pydantic>=2.0.0
httpx>=0.24.0
websockets>=11.0
-6
View File
@@ -1,6 +0,0 @@
"""
routes package — API routers for Commander AI Lab.
Each module defines a FastAPI APIRouter with real endpoint implementations.
All shared helpers, models, and state live in routes.shared.
"""
-698
View File
@@ -1,698 +0,0 @@
"""
routes/coach.py
===============
LLM-powered deck coaching endpoints:
GET /api/coach/status
GET /api/coach/decks
GET /api/coach/decks/{deck_id}/report
POST /api/coach/decks/{deck_id}
GET /api/coach/sessions
GET /api/coach/sessions/{session_id}
POST /api/coach/embeddings/download
GET /api/coach/embeddings/search
POST /api/coach/chat
POST /api/coach/apply
GET /api/coach/cards-like
POST /api/coach/reports/generate
Globals:
_coach_service, _coach_embeddings, _coach_llm
_deck_gen_v3, _deck_gen_v3_error
init_coach_service(), _build_deck_report_from_db()
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from routes.shared import (
CFG,
log_coach, log_deckgen,
_get_db_conn,
_compute_deck_analysis,
)
router = APIRouter(tags=["coach"])
# ══════════════════════════════════════════════════════════════
# Coach Service (LLM-powered deck coaching)
# ══════════════════════════════════════════════════════════════
# Global coach instances (initialized at startup)
_coach_service = None
_coach_embeddings = None
_coach_llm = None
_deck_gen_v3 = None # V3 Deck Generator (Perplexity structured output)
_deck_gen_v3_error = None # Error message if V3 init failed
def init_coach_service():
"""Initialize the coach service with LLM client and embeddings."""
global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
try:
from coach.llm_client import LMStudioClient
from coach.embeddings import MTGEmbeddingIndex
from coach.coach_service import CoachService
from coach.config import ensure_dirs
ensure_dirs()
_coach_llm = LMStudioClient()
_coach_embeddings = MTGEmbeddingIndex()
# Try to load embeddings (non-blocking — download happens on first use)
try:
from coach.config import EMBEDDINGS_NPZ
if EMBEDDINGS_NPZ.exists():
_coach_embeddings.load()
log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
else:
log_coach.warning(" Coach: Embeddings not yet downloaded (will download on first use)")
except Exception as e:
log_coach.error(f" Coach: Embeddings load failed: {e}")
_coach_service = CoachService(_coach_llm, _coach_embeddings)
# Check LLM connection
llm_status = _coach_llm.check_connection()
if llm_status.get("connected"):
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
else:
log_coach.warning(f" Coach LLM: Not connected (start LM Studio on 192.168.0.122:1234)")
log_coach.info(" Coach: Service initialized")
# Initialize V3 Deck Generator (Perplexity)
_deck_gen_v3_error = None
if CFG.pplx_api_key:
try:
from coach.clients.perplexity_client import PerplexityClient
from coach.services.deck_generator import DeckGeneratorV3
from coach.config import DECK_GEN_MODEL
pplx_client = PerplexityClient(
api_key=CFG.pplx_api_key,
model=DECK_GEN_MODEL,
)
_deck_gen_v3 = DeckGeneratorV3(
pplx_client=pplx_client,
db_conn_factory=_get_db_conn,
embedding_index=_coach_embeddings,
)
log_deckgen.info(f" Deck Gen V3: Initialized (model: {DECK_GEN_MODEL})")
except ImportError as e:
_deck_gen_v3_error = f"Missing dependency: {e}. Run: pip install openai"
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
_deck_gen_v3 = None
except Exception as e:
_deck_gen_v3_error = str(e)
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
_deck_gen_v3 = None
else:
_deck_gen_v3_error = "PPLX_API_KEY not set"
log_deckgen.info(" Deck Gen V3: Skipped (no PPLX_API_KEY)")
except Exception as e:
log_coach.error(f" Coach: Failed to initialize: {e}")
_coach_service = None
@router.get("/api/coach/status")
async def coach_status():
"""Check coach subsystem health."""
if _coach_service is None:
return {"llmConnected": False, "embeddingsLoaded": False,
"embeddingCards": 0, "deckReportsAvailable": 0,
"error": "Coach service not initialized"}
status = _coach_service.get_status()
return status.model_dump()
@router.get("/api/coach/decks")
async def coach_list_decks():
"""List all decks available for coaching (from deck builder DB)."""
conn = _get_db_conn()
rows = conn.execute(
"""SELECT d.id as deck_id, d.name as deck_name, d.commander_name,
COUNT(dc.id) as card_count
FROM decks d
LEFT JOIN deck_cards dc ON dc.deck_id = d.id
GROUP BY d.id
ORDER BY d.name"""
).fetchall()
# Also get report availability from coach service
report_ids = set()
if _coach_service:
report_ids = set(_coach_service.list_deck_reports())
decks = []
for r in rows:
deck_name = r["deck_name"]
# Check if a report exists (by deck name slug match)
has_report = any(
deck_name.lower().replace(" ", "-") == rid.lower() or
deck_name.lower() == rid.lower()
for rid in report_ids
)
decks.append({
"deck_id": r["deck_id"],
"deck_name": r["deck_name"],
"commander": r["commander_name"] or "",
"card_count": r["card_count"],
"has_report": has_report,
"report_count": 1 if has_report else 0,
"last_report_date": None,
})
return decks
@router.get("/api/coach/decks/{deck_id}/report")
async def coach_get_report(deck_id: str):
"""Get the latest DeckReport for a deck."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
report = _coach_service.load_deck_report(deck_id)
if report is None:
raise HTTPException(404, f"Deck report not found: {deck_id}")
return report.model_dump()
class CoachRequestBody(BaseModel):
goals: Optional[dict] = None
class CoachChatMessage(BaseModel):
role: str # "user" or "assistant"
content: str
class CoachChatRequest(BaseModel):
deck_id: str
messages: list[dict] # conversation history [{role, content}]
goals: Optional[dict] = None
stream: Optional[bool] = False
class CoachApplyRequest(BaseModel):
session_id: str
deck_id: int # numeric deck ID in the DB
accepted_cuts: list[str] = [] # card names to remove
accepted_adds: list[str] = [] # card names to add
class CoachGoalsRequest(BaseModel):
target_power_level: Optional[int] = None # 1-10
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
@router.post("/api/coach/decks/{deck_id}")
async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
"""Trigger a coaching session for a deck."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
# Load embeddings on first use if not loaded
if _coach_embeddings and not _coach_embeddings.loaded:
try:
_coach_embeddings.load(force_download=True)
except Exception as e:
log_coach.error(f" Coach: Embeddings download failed: {e}")
goals = None
if body and body.goals:
from coach.models import CoachGoals
try:
goals = CoachGoals(**body.goals)
except Exception:
goals = None
# Build a fallback DeckReport from the DB if no simulation report exists
fallback_report = None
try:
fallback_report = _build_deck_report_from_db(deck_id)
except Exception as e:
log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}")
try:
session = await _coach_service.run_coaching_session(deck_id, goals, fallback_report=fallback_report)
return session.model_dump()
except ValueError as e:
raise HTTPException(404, str(e))
except ConnectionError as e:
raise HTTPException(503, f"LLM connection failed: {e}")
except Exception as e:
raise HTTPException(500, f"Coach session failed: {e}")
def _build_deck_report_from_db(deck_slug: str):
"""
Build a lightweight DeckReport from the deck builder DB when no
simulation report exists. Allows the coach to analyze deck composition
even without simulation data.
"""
from coach.models import DeckReport, CardPerformance, DeckStructure
conn = _get_db_conn()
# Find the deck by slug match against the deck name
rows = conn.execute(
"SELECT id, name, commander_name, color_identity FROM decks ORDER BY id"
).fetchall()
matched_deck = None
for r in rows:
name = r["name"] or ""
slug = name.lower().replace(" ", "-")
# Also try a more thorough slugify
import re
clean_slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
if slug == deck_slug.lower() or clean_slug == deck_slug.lower() or name.lower() == deck_slug.lower():
matched_deck = r
break
if matched_deck is None:
return None
deck_id = matched_deck["id"]
deck_name = matched_deck["name"]
commander = matched_deck["commander_name"] or ""
# Parse color identity
ci_raw = matched_deck["color_identity"] or "[]"
try:
color_identity = json.loads(ci_raw) if isinstance(ci_raw, str) else ci_raw
except Exception:
color_identity = []
# Load all cards in this deck
card_rows = conn.execute(
"""SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
ce.type_line, ce.cmc, ce.oracle_text
FROM deck_cards dc
LEFT JOIN collection_entries ce ON LOWER(dc.card_name) = LOWER(ce.name)
WHERE dc.deck_id = ?""",
(deck_id,)
).fetchall()
cards = []
type_counts = {}
cmc_buckets = [0] * 8
land_count = 0
for cr in card_rows:
card_name = cr["card_name"] or ""
type_line = cr["type_line"] or ""
cmc = cr["cmc"] or 0
qty = cr["quantity"] or 1
role_tag = cr["role_tag"] or ""
# Build tags from type_line and role_tag
tags = []
if role_tag:
tags.append(role_tag)
if "Land" in type_line:
tags.append("land")
land_count += qty
elif "Creature" in type_line:
tags.append("creature")
elif "Instant" in type_line:
tags.append("instant")
elif "Sorcery" in type_line:
tags.append("sorcery")
elif "Artifact" in type_line:
tags.append("artifact")
elif "Enchantment" in type_line:
tags.append("enchantment")
elif "Planeswalker" in type_line:
tags.append("planeswalker")
# Type count
for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
if t in type_line:
type_counts[t] = type_counts.get(t, 0) + qty
# CMC bucket
bucket = min(int(cmc), 7)
cmc_buckets[bucket] += qty
# CardPerformance with zeroed-out sim stats
cards.append(CardPerformance(
name=card_name,
drawnRate=0.0,
castRate=0.0,
impactScore=0.0,
tags=tags,
))
slug = deck_slug
return DeckReport(
deckId=slug,
commander=commander,
colorIdentity=color_identity,
cards=cards,
structure=DeckStructure(
landCount=land_count,
curveBuckets=cmc_buckets,
cardTypeCounts=type_counts,
),
)
@router.get("/api/coach/sessions")
async def coach_list_sessions(deck_id: str = None):
"""List all coaching sessions, optionally filtered by deck."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
return {"sessions": _coach_service.list_sessions(deck_id)}
@router.get("/api/coach/sessions/{session_id}")
async def coach_get_session(session_id: str):
"""Get a specific coaching session."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
session = _coach_service.load_session(session_id)
if session is None:
raise HTTPException(404, f"Session not found: {session_id}")
return session.model_dump()
@router.post("/api/coach/embeddings/download")
async def coach_download_embeddings():
"""Download and convert MTG card embeddings from HuggingFace."""
if _coach_embeddings is None:
raise HTTPException(500, "Coach service not initialized")
try:
_coach_embeddings.load(force_download=True)
return {
"success": True,
"cards": _coach_embeddings.card_count,
"message": f"Loaded {_coach_embeddings.card_count} card embeddings"
}
except Exception as e:
raise HTTPException(500, f"Failed to download embeddings: {e}")
@router.get("/api/coach/embeddings/search")
async def coach_search_similar(card: str, colors: str = None, top_n: int = 10):
"""Search for similar cards by name."""
if _coach_embeddings is None or not _coach_embeddings.loaded:
raise HTTPException(503, "Embeddings not loaded")
color_filter = list(colors.upper()) if colors else None
matches = _coach_embeddings.search_similar(
query_card=card, color_filter=color_filter, top_n=top_n
)
return {"query": card, "matches": [m.to_dict() for m in matches]}
@router.post("/api/coach/chat")
async def coach_chat(body: CoachChatRequest):
"""Multi-turn coaching chat. Sends conversation history to LLM and returns response."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
# Load deck report for context
report = _coach_service.load_deck_report(body.deck_id)
# Build system prompt with report context
from coach.prompt_template import build_system_prompt
from coach.models import CoachGoals
goals = None
if body.goals:
try:
goals = CoachGoals(**body.goals)
except Exception:
goals = None
system_prompt = ""
if report:
system_prompt = build_system_prompt(report, goals)
else:
system_prompt = (
"You are an expert Magic: The Gathering Commander deck coach. "
"Answer questions about deck building, strategy, card choices, and game theory. "
"Be specific and actionable in your advice."
)
# Build messages array for LLM
messages = [{"role": "system", "content": system_prompt}]
for msg in body.messages:
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
# Call LLM with multi-turn messages
try:
import json
from urllib.request import urlopen, Request as UrlRequest
from coach.config import LM_STUDIO_URL, LM_STUDIO_TIMEOUT
model_name = _coach_llm._resolve_model()
llm_body = {
"model": model_name,
"messages": messages,
"temperature": 0.7,
"max_tokens": 4096,
}
if body.stream:
# SSE streaming response
llm_body["stream"] = True
async def generate():
import asyncio
def _stream():
req = UrlRequest(
f"{_coach_llm.base_url}/chat/completions",
data=json.dumps(llm_body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
import http.client
import urllib.parse
parsed = urllib.parse.urlparse(f"{_coach_llm.base_url}/chat/completions")
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LM_STUDIO_TIMEOUT)
conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"),
headers={"Content-Type": "application/json"})
resp = conn.getresponse()
chunks = []
while True:
line = resp.readline()
if not line:
break
line = line.decode("utf-8").strip()
if line.startswith("data: "):
data = line[6:]
if data == "[DONE]":
chunks.append("[DONE]")
break
chunks.append(data)
conn.close()
return chunks
loop = asyncio.get_event_loop()
chunks = await loop.run_in_executor(None, _stream)
for chunk_str in chunks:
if chunk_str == "[DONE]":
yield f"data: [DONE]\n\n"
break
try:
chunk = json.loads(chunk_str)
delta = chunk.get("choices", [{}])[0].get("delta", {})
content = delta.get("content", "")
if content:
# Strip think tags from streaming chunks
yield f"data: {json.dumps({'content': content})}\n\n"
except json.JSONDecodeError:
continue
return StreamingResponse(generate(), media_type="text/event-stream")
else:
# Non-streaming: regular call
req = UrlRequest(
f"{_coach_llm.base_url}/chat/completions",
data=json.dumps(llm_body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
import asyncio
loop = asyncio.get_event_loop()
def _call():
with urlopen(req, timeout=LM_STUDIO_TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
raw = await loop.run_in_executor(None, _call)
content = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
# Strip think tags
content = _coach_llm._strip_think_tags(content)
usage = raw.get("usage", {})
return {
"content": content,
"model": raw.get("model", ""),
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
}
except Exception as e:
raise HTTPException(500, f"Chat failed: {e}")
@router.post("/api/coach/apply")
async def coach_apply_suggestions(body: CoachApplyRequest):
"""Apply accepted coaching suggestions to a deck — remove cuts, add adds."""
conn = _get_db_conn()
# Verify deck exists
deck = conn.execute("SELECT * FROM decks WHERE id = ?", (body.deck_id,)).fetchone()
if not deck:
raise HTTPException(404, f"Deck {body.deck_id} not found")
results = {"cuts": [], "adds": [], "errors": []}
# Process cuts: remove cards by name
for card_name in body.accepted_cuts:
row = conn.execute(
"SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1",
(body.deck_id, card_name)
).fetchone()
if row:
conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
results["cuts"].append({"name": card_name, "status": "removed"})
else:
# Try case-insensitive
row = conn.execute(
"SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1",
(body.deck_id, card_name)
).fetchone()
if row:
conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
results["cuts"].append({"name": row["card_name"], "status": "removed"})
else:
results["errors"].append({"name": card_name, "error": "Card not found in deck"})
# Process adds: look up in collection first, then Scryfall
for card_name in body.accepted_adds:
# Try to find in collection
ce = conn.execute(
"SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
(card_name,)
).fetchone()
scryfall_id = None
resolved_name = card_name
if ce:
scryfall_id = ce["scryfall_id"]
resolved_name = ce["name"]
else:
# Try Scryfall API lookup
try:
import urllib.parse
encoded = urllib.parse.quote(card_name)
scry_req = UrlRequest(
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
headers={"User-Agent": "commander-ai-lab/1.0"}
)
with urlopen(scry_req, timeout=10) as resp:
card_data = json.loads(resp.read().decode("utf-8"))
scryfall_id = card_data.get("id")
resolved_name = card_data.get("name", card_name)
except Exception:
pass
if scryfall_id:
# Check if already in deck
existing = conn.execute(
"SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
(body.deck_id, scryfall_id)
).fetchone()
if not existing:
conn.execute(
"INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)",
(body.deck_id, scryfall_id, resolved_name)
)
results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"})
else:
results["adds"].append({"name": resolved_name, "status": "already_in_deck"})
else:
results["errors"].append({"name": card_name, "error": "Could not resolve card"})
conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,))
conn.commit()
results["total_cuts"] = len(results["cuts"])
results["total_adds"] = len([a for a in results["adds"] if a["status"] == "added"])
return results
@router.get("/api/coach/cards-like")
async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
"""Find cards similar to a given card using embeddings. UI-friendly version."""
if _coach_embeddings is None or not _coach_embeddings.loaded:
raise HTTPException(503, "Embeddings not loaded")
color_filter = list(colors.upper()) if colors else None
matches = _coach_embeddings.search_similar(
query_card=card, color_filter=color_filter, top_n=top_n
)
conn = _get_db_conn()
results = []
for m in matches:
# Check if card is in collection
owned = conn.execute(
"SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)",
(m.name,)
).fetchone()
owned_qty = (owned["qty"] or 0) if owned else 0
# Get image URL and price
ce = conn.execute(
"SELECT image_url, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
(m.name,)
).fetchone()
results.append({
"name": m.name,
"similarity": round(m.similarity, 4),
"types": m.types,
"mana_value": m.mana_value,
"mana_cost": m.mana_cost,
"text": m.text[:200] if m.text else "",
"owned_qty": owned_qty,
"image_url": ce["image_url"] if ce else None,
"tcg_price": ce["tcg_price"] if ce else None,
})
return {"query": card, "results": results}
@router.post("/api/coach/reports/generate")
async def coach_generate_reports():
"""Rebuild all deck reports from batch result JSONs in results/."""
try:
from coach.report_generator import generate_deck_reports
lab_root = Path(__file__).parent
results_dir = str(lab_root / CFG.results_dir)
reports_dir = str(lab_root / "deck-reports")
updated = generate_deck_reports(results_dir, reports_dir)
return {
"status": "ok",
"decksUpdated": updated,
"count": len(updated),
"message": f"Generated reports for {len(updated)} decks" if updated else "No batch results found",
}
except Exception as e:
raise HTTPException(500, f"Report generation failed: {e}")
-976
View File
@@ -1,976 +0,0 @@
"""
routes/collection.py
====================
Collection management endpoints:
GET /api/collection
GET /api/collection/export
GET /api/collection/sets
GET /api/collection/keywords
POST /api/collection/import
GET /api/collection/{cardId}
PATCH /api/collection/{cardId}
GET /api/cache/scryfall
DELETE /api/cache/scryfall
POST /api/cache/scryfall/evict
"""
from __future__ import annotations
import csv
import io
import json
import os
import re
import sqlite3
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from routes.shared import (
CFG,
_get_db_conn,
_row_to_dict,
_add_image_url,
_build_collection_filters,
_detect_card_roles,
_classify_card_type,
_enrich_from_scryfall,
_fetch_scryfall_api,
_parse_finish,
_parse_text_line,
_parse_csv_content,
_auto_infer_mapping,
_scryfall_cache,
_scryfall_rate_limit,
_snake_to_camel,
VALID_SORT_FIELDS,
_JSON_FIELDS,
SCRYFALL_CACHE_DB_PATH,
SCRYFALL_CACHE_TTL_SECONDS,
_API_HEADERS,
log_collect,
log_cache,
)
router = APIRouter(tags=["collection"])
@router.get("/api/collection/export")
async def export_collection(
format: str = "INTERNAL_CSV",
q: Optional[str] = None,
colors: Optional[str] = None,
types: Optional[str] = None,
isLegendary: Optional[bool] = None,
isBasic: Optional[bool] = None,
isGameChanger: Optional[bool] = None,
highSalt: Optional[bool] = None,
finish: Optional[str] = None,
cmcMin: Optional[float] = None,
cmcMax: Optional[float] = None,
priceMin: Optional[float] = None,
priceMax: Optional[float] = None,
category: Optional[str] = None,
):
"""Export collection in various formats with optional filters."""
where_str, params = _build_collection_filters(
q=q, colors=colors, types=types, isLegendary=isLegendary,
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
priceMin=priceMin, priceMax=priceMax, category=category,
)
sql = f"SELECT * FROM collection_entries {where_str} ORDER BY name ASC"
conn = _get_db_conn()
rows = [_row_to_dict(r) for r in conn.execute(sql, params).fetchall()]
fmt = format.upper()
output = io.StringIO()
if fmt == "INTERNAL_CSV":
if rows:
fieldnames = list(rows[0].keys())
else:
fieldnames = [
"id", "name", "type_line", "subtypes", "is_legendary", "is_basic",
"color_identity", "cmc", "oracle_text", "keywords", "tcg_price",
"salt_score", "is_game_changer", "category", "scryfall_id", "tcgplayer_id",
"quantity", "finish", "condition", "language", "notes", "tags",
"set_code", "collector_number", "created_at", "updated_at",
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
filename = "collection_export.csv"
media_type = "text/csv"
elif fmt == "MOXFIELD_CSV":
fieldnames = ["Count", "Name", "Edition", "Condition", "Language", "Foil", "Collector Number"]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
finish_val = row.get("finish", "NORMAL").upper()
foil_val = "foil" if finish_val == "FOIL" else ("etched" if finish_val == "ETCHED" else "")
writer.writerow({
"Count": row.get("quantity", 1),
"Name": row.get("name", ""),
"Edition": row.get("set_code", "").upper(),
"Condition": row.get("condition", ""),
"Language": row.get("language", ""),
"Foil": foil_val,
"Collector Number": row.get("collector_number", ""),
})
filename = "collection_moxfield.csv"
media_type = "text/csv"
elif fmt == "TEXT":
lines = []
for row in rows:
qty = row.get("quantity", 1)
name = row.get("name", "")
set_code = row.get("set_code", "").upper()
coll_num = row.get("collector_number", "")
if set_code and coll_num:
lines.append(f"{qty} {name} ({set_code}) {coll_num}")
elif set_code:
lines.append(f"{qty} {name} ({set_code})")
else:
lines.append(f"{qty} {name}")
output.write("\n".join(lines))
filename = "collection_export.txt"
media_type = "text/plain"
else:
raise HTTPException(400, f"Unknown format: {format}. Use INTERNAL_CSV, MOXFIELD_CSV, or TEXT.")
content = output.getvalue()
def iter_content():
yield content.encode("utf-8")
return StreamingResponse(
iter_content(),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/api/collection/sets")
async def collection_sets():
"""Return distinct set codes + names in the collection (for filter autocomplete)."""
conn = _get_db_conn()
rows = conn.execute(
"SELECT DISTINCT UPPER(set_code) as code, set_name as name FROM collection_entries WHERE set_code != '' ORDER BY set_name ASC"
).fetchall()
result = []
for r in rows:
code = r["code"] if "code" in r.keys() else r[0]
name = r["name"] if "name" in r.keys() else r[1]
if code:
result.append({"code": code, "name": name or code})
return JSONResponse(result)
@router.get("/api/collection/keywords")
async def collection_keywords():
"""Return distinct keywords found in the collection."""
conn = _get_db_conn()
rows = conn.execute("SELECT keywords FROM collection_entries WHERE keywords != '' AND keywords != '[]'").fetchall()
kw_set = set()
for r in rows:
try:
kws = json.loads(r["keywords"]) if isinstance(r["keywords"], str) else r["keywords"]
if isinstance(kws, list):
for k in kws:
if k:
kw_set.add(k)
except (json.JSONDecodeError, TypeError):
pass
return JSONResponse(sorted(kw_set))
@router.get("/api/collection")
async def list_collection(
page: int = 1,
pageSize: int = 50,
q: Optional[str] = None,
sortField: Optional[str] = None,
sortDir: Optional[str] = None,
colors: Optional[str] = None,
types: Optional[str] = None,
isLegendary: Optional[bool] = None,
isBasic: Optional[bool] = None,
isGameChanger: Optional[bool] = None,
highSalt: Optional[bool] = None,
finish: Optional[str] = None,
cmcMin: Optional[float] = None,
cmcMax: Optional[float] = None,
priceMin: Optional[float] = None,
priceMax: Optional[float] = None,
category: Optional[str] = None,
deck_id: Optional[int] = None,
rarity: Optional[str] = None,
setCode: Optional[str] = None,
powerMin: Optional[str] = None,
powerMax: Optional[str] = None,
toughMin: Optional[str] = None,
toughMax: Optional[str] = None,
keyword: Optional[str] = None,
edhrecMin: Optional[int] = None,
edhrecMax: Optional[int] = None,
qtyMin: Optional[int] = None,
qtyMax: Optional[int] = None,
):
"""List collection entries with search, sort, and filter.
Optional deck_id: when provided, includes in_deck_quantity for each result.
"""
where_str, params = _build_collection_filters(
q=q, colors=colors, types=types, isLegendary=isLegendary,
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
priceMin=priceMin, priceMax=priceMax, category=category,
rarity=rarity, setCode=setCode, powerMin=powerMin, powerMax=powerMax,
toughMin=toughMin, toughMax=toughMax, keyword=keyword,
edhrecMin=edhrecMin, edhrecMax=edhrecMax, qtyMin=qtyMin, qtyMax=qtyMax,
)
# Validate sort
sort_field = sortField if sortField in VALID_SORT_FIELDS else "name"
sort_dir = "DESC" if (sortDir or "").upper() == "DESC" else "ASC"
conn = _get_db_conn()
# Count total
count_sql = f"SELECT COUNT(*) FROM collection_entries {where_str}"
total = conn.execute(count_sql, params).fetchone()[0]
# Paginate
offset = (max(1, page) - 1) * pageSize
data_sql = f"""
SELECT * FROM collection_entries {where_str}
ORDER BY {sort_field} {sort_dir}
LIMIT ? OFFSET ?
"""
rows = conn.execute(data_sql, params + [pageSize, offset]).fetchall()
items = [_add_image_url(_row_to_dict(r)) for r in rows]
# If deck_id provided, annotate each item with how many copies are already in that deck
if deck_id is not None:
# Build a map of scryfall_id -> quantity in deck
deck_card_rows = conn.execute(
"SELECT scryfall_id, SUM(quantity) as qty FROM deck_cards WHERE deck_id = ? GROUP BY scryfall_id",
(deck_id,)
).fetchall()
in_deck_map = {row["scryfall_id"]: row["qty"] for row in deck_card_rows}
for item in items:
item["in_deck_quantity"] = in_deck_map.get(item.get("scryfall_id", ""), 0)
return {"items": items, "page": page, "pageSize": pageSize, "total": total}
@router.get("/api/collection/{cardId}")
async def get_collection_card(cardId: int):
"""Get a single collection entry by ID."""
conn = _get_db_conn()
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
if not row:
raise HTTPException(404, f"Card with id {cardId} not found")
return _add_image_url(_row_to_dict(row))
@router.patch("/api/collection/{cardId}")
async def update_collection_card(cardId: int, body: dict):
"""Update mutable fields of a collection entry."""
conn = _get_db_conn()
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
if not row:
raise HTTPException(404, f"Card with id {cardId} not found")
updates = {}
if "category" in body:
cat = body["category"]
if isinstance(cat, list):
updates["category"] = json.dumps(cat)
else:
updates["category"] = cat
if "tags" in body:
updates["tags"] = str(body["tags"])
if "notes" in body:
updates["notes"] = str(body["notes"])
if "finish" in body:
finish_val = str(body["finish"]).upper()
if finish_val not in ("NORMAL", "FOIL", "ETCHED"):
raise HTTPException(400, "finish must be NORMAL, FOIL, or ETCHED")
updates["finish"] = finish_val
if not updates:
return _add_image_url(_row_to_dict(row))
updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [cardId]
conn.execute(f"UPDATE collection_entries SET {set_clause} WHERE id = ?", values)
conn.commit()
updated_row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
return _add_image_url(_row_to_dict(updated_row))
# ══════════════════════════════════════════════════════════════
# Collection Import Helpers
# ══════════════════════════════════════════════════════════════
# ── Scryfall Response Cache ─────────────────────────────────
# SQLite-backed cache for Scryfall API responses.
# Keyed by (name, set_code, collector_number). Default TTL: 7 days.
# Scryfall data changes infrequently (set releases ~every 3 months).
# Eliminates redundant HTTP calls across imports and re-enrichments.
SCRYFALL_CACHE_DB_PATH = Path(__file__).parent / "scryfall_cache.db"
SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600)) # 7 days
class _ScryfallCache:
"""
Thread-safe SQLite cache for raw Scryfall JSON responses.
Schema:
cache_key TEXT PRIMARY KEY — "name|set|cn" normalised lowercase
json_blob TEXT — raw Scryfall API JSON response
fetched_at TEXT — ISO-8601 UTC timestamp
"""
def __init__(self, db_path: Path = None, ttl_seconds: int = None):
self._db_path = db_path or SCRYFALL_CACHE_DB_PATH
self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS
self._lock = threading.Lock()
self._conn: Optional[sqlite3.Connection] = None
self._hits = 0
self._misses = 0
self._init_db()
# ── internal ────────────────────────────────────────────
def _init_db(self):
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("""
CREATE TABLE IF NOT EXISTS scryfall_cache (
cache_key TEXT PRIMARY KEY,
json_blob TEXT NOT NULL,
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
self._conn.commit()
@staticmethod
def _make_key(name: str, set_code: str, collector_number: str) -> str:
return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}"
# ── public API ──────────────────────────────────────────
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
"""Return cached Scryfall JSON dict, or None if missing / expired."""
key = self._make_key(name, set_code, collector_number)
with self._lock:
row = self._conn.execute(
"SELECT json_blob, fetched_at FROM scryfall_cache WHERE cache_key = ?",
(key,),
).fetchone()
if not row:
self._misses += 1
return None
# TTL check
try:
fetched = datetime.fromisoformat(row[1])
age = (datetime.utcnow() - fetched).total_seconds()
if age > self._ttl:
self._misses += 1
return None
except (ValueError, TypeError):
self._misses += 1
return None
self._hits += 1
try:
return json.loads(row[0])
except json.JSONDecodeError:
self._misses += 1
return None
def put(self, name: str, set_code: str, collector_number: str, card_data: dict):
"""Store a Scryfall response in the cache."""
key = self._make_key(name, set_code, collector_number)
blob = json.dumps(card_data, separators=(",", ":"))
with self._lock:
self._conn.execute(
"INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob, fetched_at) "
"VALUES (?, ?, datetime('now'))",
(key, blob),
)
self._conn.commit()
def stats(self) -> dict:
"""Return cache statistics."""
with self._lock:
total = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
oldest_row = self._conn.execute(
"SELECT MIN(fetched_at) FROM scryfall_cache"
).fetchone()
newest_row = self._conn.execute(
"SELECT MAX(fetched_at) FROM scryfall_cache"
).fetchone()
db_size_bytes = os.path.getsize(str(self._db_path)) if self._db_path.exists() else 0
return {
"total_entries": total,
"hits": self._hits,
"misses": self._misses,
"hit_rate": round(self._hits / max(self._hits + self._misses, 1) * 100, 1),
"ttl_seconds": self._ttl,
"ttl_days": round(self._ttl / 86400, 1),
"oldest_entry": oldest_row[0] if oldest_row else None,
"newest_entry": newest_row[0] if newest_row else None,
"db_size_kb": round(db_size_bytes / 1024, 1),
"db_path": str(self._db_path),
}
def clear(self) -> int:
"""Delete all cached entries. Returns count of deleted rows."""
with self._lock:
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
self._conn.execute("DELETE FROM scryfall_cache")
self._conn.commit()
self._conn.execute("VACUUM") # must run outside transaction
self._hits = 0
self._misses = 0
return count
def evict_expired(self) -> int:
"""Remove entries older than TTL. Returns count of evicted rows."""
cutoff = datetime.utcnow().isoformat()
with self._lock:
self._conn.execute(
"DELETE FROM scryfall_cache WHERE "
"(julianday(?) - julianday(fetched_at)) * 86400 > ?",
(cutoff, self._ttl),
)
deleted = self._conn.total_changes
self._conn.commit()
return deleted
_scryfall_cache = _ScryfallCache()
# Scryfall rate-limit semaphore (max 10 req/sec)
_scryfall_lock = threading.Lock()
_scryfall_last_call = 0.0
def _scryfall_rate_limit():
"""Enforce at most 10 Scryfall requests per second via simple sleep."""
global _scryfall_last_call
with _scryfall_lock:
now = time.monotonic()
elapsed = now - _scryfall_last_call
if elapsed < 0.1:
time.sleep(0.1 - elapsed)
_scryfall_last_call = time.monotonic()
def _parse_finish(raw: str) -> str:
"""Normalize finish string to NORMAL|FOIL|ETCHED."""
if not raw:
return "NORMAL"
r = raw.strip().lower()
if r in ("etched", "foil etched", "foil_etched", "etched foil"):
return "ETCHED"
if r in ("yes", "foil", "true", "1"):
return "FOIL"
return "NORMAL"
def _parse_text_line(line: str) -> dict:
"""
Parse a text import line into {name, quantity, set_code, collector_number}.
Supports formats:
- "3 Sol Ring (CMR) 123"
- "1x Sol Ring (CMR)"
- "Sol Ring [CMR:123]"
- "1 Sol Ring"
"""
line = line.strip()
if not line or line.startswith("#") or line.startswith("//"):
return {}
result = {"name": "", "quantity": 1, "set_code": "", "collector_number": ""}
# Strip quantity prefix
qty_match = re.match(r"^(\d+)x?\s+", line)
if qty_match:
result["quantity"] = int(qty_match.group(1))
line = line[qty_match.end():]
# Format: "Name [SET:CollNum]"
bracket_match = re.search(r"\[([A-Za-z0-9]+):(\S+)\]\s*$", line)
if bracket_match:
result["set_code"] = bracket_match.group(1).lower()
result["collector_number"] = bracket_match.group(2)
line = line[:bracket_match.start()].strip()
result["name"] = line
return result
# Format: "Name (SET) CollNum"
paren_coll_match = re.search(r"\(([A-Za-z0-9]+)\)\s+(\S+)\s*$", line)
if paren_coll_match:
result["set_code"] = paren_coll_match.group(1).lower()
result["collector_number"] = paren_coll_match.group(2)
line = line[:paren_coll_match.start()].strip()
result["name"] = line
return result
# Format: "Name (SET)"
paren_match = re.search(r"\(([A-Za-z0-9]+)\)\s*$", line)
if paren_match:
result["set_code"] = paren_match.group(1).lower()
line = line[:paren_match.start()].strip()
result["name"] = line
return result
result["name"] = line.strip()
return result
def _auto_infer_mapping(headers: list) -> dict:
"""
Auto-infer column → field mapping from header names.
Returns dict of {header: field_key}.
Field keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
"""
mapping = {}
header_lower = {h: h.lower().strip() for h in headers}
field_patterns = {
"name": ["name", "card name", "card_name", "cardname", "title"],
"quantity": ["quantity", "qty", "count", "amount", "number", "#"],
"set_code": ["set", "set code", "set_code", "edition", "set_name", "edition code"],
"collector_number": [
"collector number", "collector_number", "collectornumber",
"col #", "col#", "number", "card number",
],
"finish": ["finish", "foil", "printing", "treatment"],
"condition": ["condition", "cond", "grade"],
"language": ["language", "lang", "locale"],
"notes": ["notes", "note", "comments", "comment"],
"tags": ["tags", "tag", "labels", "label"],
}
for header, lower_h in header_lower.items():
for field, patterns in field_patterns.items():
if lower_h in patterns:
mapping[header] = field
break
return mapping
def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> list:
"""
Parse CSV/text content into a list of RawImportRow dicts.
Returns list of dicts with keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
"""
rows = []
source_upper = (source or "").upper()
if source_upper == "TEXT":
for line in content.splitlines():
parsed = _parse_text_line(line)
if parsed.get("name"):
rows.append(parsed)
return rows
# CSV-based sources
reader = csv.DictReader(io.StringIO(content))
headers = reader.fieldnames or []
# Build effective mapping
if mapping:
col_map = {h: mapping.get(h, "") for h in headers}
else:
col_map = _auto_infer_mapping(headers)
# Normalize col_map field values to lowercase so both "NAME" (UI) and "name" (auto-infer) work
col_map = {h: (v.lower() if isinstance(v, str) else v) for h, v in col_map.items()}
for csv_row in reader:
row = {
"name": "",
"quantity": 1,
"set_code": "",
"collector_number": "",
"finish": "NORMAL",
"condition": "",
"language": "",
"notes": "",
"tags": "",
}
for header, field in col_map.items():
val = csv_row.get(header, "").strip()
if not val or field == "ignore" or not field:
continue
if field == "name":
# Strip finish suffix like "(Foil Etched)"
val = re.sub(r"\s*\(foil(?: etched)?\)\s*$", "", val, flags=re.IGNORECASE).strip()
row["name"] = val
elif field == "quantity":
try:
row["quantity"] = int(float(val))
except ValueError:
row["quantity"] = 1
elif field in ("set_code", "set_code_secondary"):
row["set_code"] = val.lower()
elif field in ("collector_number", "collector_number_secondary"):
row["collector_number"] = val
elif field == "finish":
row["finish"] = _parse_finish(val)
elif field == "condition":
row["condition"] = val
elif field == "language":
row["language"] = val
elif field == "notes":
row["notes"] = val
elif field == "tags":
row["tags"] = val
if row["name"]:
rows.append(row)
return rows
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
"""
Fetch raw card data from Scryfall API (with caching).
Returns the raw Scryfall JSON dict, or a dict with '_error' on failure.
Cache is checked first; live HTTP call only on miss.
"""
from urllib.parse import quote
# ── Cache check ──────────────────────────────────────
cached = _scryfall_cache.get(name, set_code, collector_number)
if cached is not None:
return cached
# ── Live fetch ───────────────────────────────────────
card_data = None
last_error = ""
# Try set + collector number first
if set_code and collector_number:
_scryfall_rate_limit()
try:
url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}"
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=10) as resp:
card_data = json.loads(resp.read().decode("utf-8"))
except Exception as e:
last_error = str(e)
card_data = None
# Fall back to exact name lookup
if not card_data:
_scryfall_rate_limit()
try:
encoded_name = quote(name)
url = f"https://api.scryfall.com/cards/named?exact={encoded_name}"
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=10) as resp:
card_data = json.loads(resp.read().decode("utf-8"))
except Exception as e:
last_error = str(e)
return {"_error": f"Scryfall lookup failed for '{name}': {last_error}"}
if not card_data or card_data.get("object") == "error":
err_detail = card_data.get("details", "unknown error") if card_data else last_error
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
# ── Cache the successful response ────────────────────
_scryfall_cache.put(name, set_code, collector_number, card_data)
# Also cache under the Scryfall-resolved name (handles spelling normalisation)
resolved_name = card_data.get("name", "")
if resolved_name and resolved_name.lower() != name.strip().lower():
_scryfall_cache.put(resolved_name, set_code, collector_number, card_data)
return card_data
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
"""
Fetch card data from Scryfall (cached) and return enriched fields dict.
Tries set+collectorNumber first, then exact name lookup.
Returns empty dict on failure.
"""
card_data = _fetch_scryfall_api(name, set_code, collector_number)
if not card_data or "_error" in card_data:
return card_data or {}
# Extract fields
type_line = card_data.get("type_line", "")
color_identity = card_data.get("color_identity", [])
keywords = card_data.get("keywords", [])
# Parse subtypes (after em-dash or hyphen)
subtypes = []
for sep in [" \u2014 ", " - "]:
if sep in type_line:
parts = type_line.split(sep, 1)
subtypes = [s.strip() for s in parts[1].split() if s.strip()]
break
is_legendary = 1 if "Legendary" in type_line else 0
is_basic = 1 if "Basic" in type_line else 0
# Get price
prices = card_data.get("prices", {})
tcg_price = 0.0
try:
price_val = prices.get("usd") or prices.get("usd_foil") or "0"
tcg_price = float(price_val) if price_val else 0.0
except (ValueError, TypeError):
tcg_price = 0.0
# Handle double-faced cards: oracle_text may be on card_faces
oracle_text = card_data.get("oracle_text", "")
if not oracle_text and card_data.get("card_faces"):
face_texts = [f.get("oracle_text", "") for f in card_data["card_faces"]]
oracle_text = "\n//\n".join(t for t in face_texts if t)
# Power/toughness (may be on card_faces for DFCs)
power = card_data.get("power", "")
toughness = card_data.get("toughness", "")
if not power and card_data.get("card_faces"):
power = card_data["card_faces"][0].get("power", "")
toughness = card_data["card_faces"][0].get("toughness", "")
# Mana cost (may be on card_faces for DFCs)
mana_cost = card_data.get("mana_cost", "")
if not mana_cost and card_data.get("card_faces"):
mana_cost = card_data["card_faces"][0].get("mana_cost", "")
# Auto-detect functional categories
auto_roles = _detect_card_roles(oracle_text, type_line, keywords)
return {
"name": card_data.get("name", name),
"type_line": type_line,
"subtypes": json.dumps(subtypes),
"is_legendary": is_legendary,
"is_basic": is_basic,
"color_identity": json.dumps(color_identity),
"cmc": card_data.get("cmc", 0.0),
"mana_cost": mana_cost,
"oracle_text": oracle_text,
"keywords": json.dumps(keywords),
"power": power or "",
"toughness": toughness or "",
"rarity": card_data.get("rarity", ""),
"set_name": card_data.get("set_name", ""),
"edhrec_rank": card_data.get("edhrec_rank", 0) or 0,
"tcg_price": tcg_price,
"salt_score": 0.0,
"is_game_changer": 0,
"category": json.dumps(auto_roles),
"scryfall_id": card_data.get("id", ""),
"tcgplayer_id": str(card_data.get("tcgplayer_id", "")),
}
# ══════════════════════════════════════════════════════════════
# Collection Import Endpoint
# ══════════════════════════════════════════════════════════════
@router.post("/api/collection/import")
async def import_collection(body: dict):
"""
Import cards into the collection from CSV, Moxfield, Archidekt, or plain text.
Body:
{
"source": "CSV" | "MOXFIELD" | "ARCHIDEKT" | "TEXT",
"mode": "MERGE" | "REPLACE",
"content": "...csv or text content...",
"mapping": { ... optional column mapping ... }
}
"""
source = str(body.get("source", "CSV")).upper()
mode = str(body.get("mode", "MERGE")).upper()
content = str(body.get("content", ""))
mapping = body.get("mapping", {})
if not content.strip():
raise HTTPException(400, "content is required")
imported_count = 0
updated_count = 0
failed_count = 0
errors = []
conn = _get_db_conn()
# REPLACE mode: clear existing entries
if mode == "REPLACE":
conn.execute("DELETE FROM collection_entries")
conn.commit()
# Parse the content into raw rows
try:
raw_rows = _parse_csv_content(content, source, mapping if mapping else None)
except Exception as e:
raise HTTPException(400, f"Failed to parse content: {str(e)}")
for raw in raw_rows:
name = raw.get("name", "").strip()
if not name:
continue
quantity = int(raw.get("quantity", 1))
set_code = raw.get("set_code", "").lower()
collector_number = raw.get("collector_number", "")
finish = _parse_finish(raw.get("finish", ""))
condition = raw.get("condition", "")
language = raw.get("language", "")
notes = raw.get("notes", "")
tags = raw.get("tags", "")
# Scryfall enrichment
try:
enriched = _enrich_from_scryfall(name, set_code, collector_number)
except Exception as e:
errors.append(f"Scryfall error for '{name}': {str(e)}")
failed_count += 1
continue
if not enriched or "_error" in enriched:
err_msg = enriched.get("_error", f"Card not found on Scryfall: '{name}'") if enriched else f"Card not found on Scryfall: '{name}'"
errors.append(err_msg)
failed_count += 1
continue
# Use Scryfall-resolved name if available
resolved_name = enriched.get("name", name)
# Check for existing entry (identity key)
existing = conn.execute(
"""SELECT id, quantity FROM collection_entries
WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""",
(resolved_name, set_code, collector_number, finish),
).fetchone()
if existing and mode == "MERGE":
# Update quantity
new_qty = existing["quantity"] + quantity
conn.execute(
"UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?",
(new_qty, existing["id"]),
)
conn.commit()
updated_count += 1
else:
# Insert new row
conn.execute(
"""INSERT INTO collection_entries
(name, type_line, subtypes, is_legendary, is_basic, color_identity,
cmc, mana_cost, oracle_text, keywords,
power, toughness, rarity, set_name, edhrec_rank,
tcg_price, salt_score, is_game_changer,
category, scryfall_id, tcgplayer_id,
quantity, finish, condition, language, notes, tags,
set_code, collector_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
resolved_name,
enriched.get("type_line", ""),
enriched.get("subtypes", "[]"),
enriched.get("is_legendary", 0),
enriched.get("is_basic", 0),
enriched.get("color_identity", "[]"),
enriched.get("cmc", 0.0),
enriched.get("mana_cost", ""),
enriched.get("oracle_text", ""),
enriched.get("keywords", "[]"),
enriched.get("power", ""),
enriched.get("toughness", ""),
enriched.get("rarity", ""),
enriched.get("set_name", ""),
enriched.get("edhrec_rank", 0),
enriched.get("tcg_price", 0.0),
enriched.get("salt_score", 0.0),
enriched.get("is_game_changer", 0),
enriched.get("category", "[]"),
enriched.get("scryfall_id", ""),
enriched.get("tcgplayer_id", ""),
quantity,
finish,
condition,
language,
notes,
tags,
set_code,
collector_number,
),
)
conn.commit()
imported_count += 1
return {
"importedCount": imported_count,
"updatedCount": updated_count,
"failedCount": failed_count,
"errors": errors,
}
# ══════════════════════════════════════════════════════════════
# Scryfall Cache Management Endpoints
# ══════════════════════════════════════════════════════════════
@router.get("/api/cache/scryfall")
async def scryfall_cache_stats():
"""Return Scryfall response cache statistics."""
return _scryfall_cache.stats()
@router.delete("/api/cache/scryfall")
async def scryfall_cache_clear():
"""Clear all cached Scryfall responses."""
deleted = _scryfall_cache.clear()
return {"cleared": deleted, "message": f"Deleted {deleted} cached entries"}
@router.post("/api/cache/scryfall/evict")
async def scryfall_cache_evict_expired():
"""Remove only expired entries (older than TTL) from the cache."""
evicted = _scryfall_cache.evict_expired()
return {"evicted": evicted, "message": f"Evicted {evicted} expired entries"}
# ══════════════════════════════════════════════════════════════
# Deck Builder Helpers
# ══════════════════════════════════════════════════════════════
# Type classification priority order
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
# Target ranges: {type: [min, max]}
_TYPE_TARGETS = {
"Land": [36, 38],
"Instant": [9, 11],
"Sorcery": [7, 9],
"Artifact": [9, 11],
"Creature": [20, 30],
"Enchantment": [5, 10],
"Planeswalker": [0, 5],
}
File diff suppressed because it is too large. Load diff
-1601
View File
File diff suppressed because it is too large. Load diff
-738
View File
@@ -1,738 +0,0 @@
"""
routes/deepseek.py
==================
Python simulator & DeepSeek AI opponent endpoints:
POST /api/sim/run
GET /api/sim/status
GET /api/sim/result
POST /api/sim/run-from-deck
POST /api/sim/run-deepseek
POST /api/deepseek/connect
GET /api/deepseek/status
POST /api/deepseek/configure
GET /api/deepseek/logs
"""
from __future__ import annotations
import json
import os
import threading as _threading
import uuid as _uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi import Request as FastAPIRequest
from fastapi.responses import JSONResponse
from routes.shared import (
CFG, log_sim,
_get_db_conn,
_ml_logging_enabled,
_get_deepseek_brain,
)
router = APIRouter(tags=["deepseek"])
# In-memory store for simulation runs
_sim_runs = {} # sim_id -> { status, result, error }
_sim_lock = _threading.Lock()
def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int, deck_name: str, record_logs: bool):
"""Background thread for simulations with full card data from the DB."""
try:
import sys, os
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src')
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.lab.experiments import _generate_training_deck
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
# Build deck with real card data from DB
deck_a = []
for cd in card_data:
c = Card(name=cd['name'])
if cd.get('type_line'):
c.type_line = cd['type_line']
if cd.get('cmc'):
c.cmc = float(cd['cmc'])
if cd.get('power') and cd.get('toughness'):
c.power = str(cd['power'])
c.toughness = str(cd['toughness'])
c.pt = c.power + '/' + c.toughness
if cd.get('oracle_text'):
c.oracle_text = cd['oracle_text']
if cd.get('mana_cost'):
c.mana_cost = cd['mana_cost']
if cd.get('keywords'):
kw = cd['keywords']
if isinstance(kw, str):
try:
import json as _json
kw = _json.loads(kw)
except Exception:
kw = []
if isinstance(kw, list):
c.keywords = kw
# Enrich fills in flags like is_removal, is_ramp, is_board_wipe
enrich_card(c)
deck_a.append(c)
deck_b = _generate_training_deck()
engine = GameEngine(max_turns=25, record_log=record_logs)
import time
start = time.time()
wins = 0
losses = 0
total_turns = 0
total_damage_dealt = 0
total_damage_received = 0
total_spells_cast = 0
total_creatures_played = 0
total_removal_used = 0
total_ramp_played = 0
total_cards_drawn = 0
total_max_board = 0
game_results = []
for i in range(num_games):
result = engine.run(deck_a, deck_b, name_a=deck_name, name_b="Training Deck")
game_data = result.to_dict()
game_data['gameNumber'] = i + 1
game_results.append(game_data)
if result.winner == 0:
wins += 1
else:
losses += 1
total_turns += result.turns
if result.player_a_stats:
total_damage_dealt += result.player_a_stats.damage_dealt
total_damage_received += result.player_a_stats.damage_received
total_spells_cast += result.player_a_stats.spells_cast
total_creatures_played += result.player_a_stats.creatures_played
total_removal_used += result.player_a_stats.removal_used
total_ramp_played += result.player_a_stats.ramp_played
total_cards_drawn += result.player_a_stats.cards_drawn
total_max_board += result.player_a_stats.max_board_size
with _sim_lock:
_sim_runs[sim_id]['completed'] = i + 1
elapsed = time.time() - start
n = num_games
summary = {
'deckName': deck_name,
'opponentName': 'Training Deck',
'totalGames': n,
'wins': wins,
'losses': losses,
'winRate': round(wins / n * 100, 1) if n > 0 else 0.0,
'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0,
'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0,
'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0,
'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0,
'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0,
'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0,
'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0,
'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0,
'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0,
'elapsedSeconds': round(elapsed, 3),
}
with _sim_lock:
_sim_runs[sim_id]['status'] = 'complete'
_sim_runs[sim_id]['result'] = {
'summary': summary,
'games': game_results,
}
except Exception as e:
import traceback
with _sim_lock:
_sim_runs[sim_id]['status'] = 'error'
_sim_runs[sim_id]['error'] = str(e)
traceback.print_exc()
def _run_sim_thread(sim_id: str, decklist: list, num_games: int, deck_name: str, record_logs: bool):
"""Background thread for running Monte Carlo simulations."""
try:
import sys, os
# Add src/ to path if needed for the simulator package
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src')
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.rules import enrich_card, parse_decklist
from commander_ai_lab.lab.experiments import build_deck, _generate_training_deck
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
deck_a = build_deck(decklist)
deck_b = _generate_training_deck()
engine = GameEngine(max_turns=25, record_log=record_logs)
import time
start = time.time()
wins = 0
losses = 0
total_turns = 0
total_damage_dealt = 0
total_damage_received = 0
total_spells_cast = 0
total_creatures_played = 0
total_removal_used = 0
total_ramp_played = 0
total_cards_drawn = 0
total_max_board = 0
game_results = []
for i in range(num_games):
result = engine.run(deck_a, deck_b, name_a=deck_name, name_b="Training Deck")
game_data = result.to_dict()
game_data['gameNumber'] = i + 1
game_results.append(game_data)
if result.winner == 0:
wins += 1
else:
losses += 1
total_turns += result.turns
if result.player_a_stats:
total_damage_dealt += result.player_a_stats.damage_dealt
total_damage_received += result.player_a_stats.damage_received
total_spells_cast += result.player_a_stats.spells_cast
total_creatures_played += result.player_a_stats.creatures_played
total_removal_used += result.player_a_stats.removal_used
total_ramp_played += result.player_a_stats.ramp_played
total_cards_drawn += result.player_a_stats.cards_drawn
total_max_board += result.player_a_stats.max_board_size
# Update progress
with _sim_lock:
_sim_runs[sim_id]['completed'] = i + 1
elapsed = time.time() - start
n = num_games
summary = {
'deckName': deck_name,
'opponentName': 'Training Deck',
'totalGames': n,
'wins': wins,
'losses': losses,
'winRate': round(wins / n * 100, 1) if n > 0 else 0.0,
'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0,
'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0,
'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0,
'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0,
'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0,
'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0,
'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0,
'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0,
'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0,
'elapsedSeconds': round(elapsed, 3),
}
with _sim_lock:
_sim_runs[sim_id]['status'] = 'complete'
_sim_runs[sim_id]['result'] = {
'summary': summary,
'games': game_results,
}
except Exception as e:
import traceback
with _sim_lock:
_sim_runs[sim_id]['status'] = 'error'
_sim_runs[sim_id]['error'] = str(e)
traceback.print_exc()
@router.post('/api/sim/run')
async def sim_run(request: FastAPIRequest):
"""Start a Monte Carlo simulation with the Python engine."""
body = await request.json()
decklist = body.get('decklist', [])
num_games = body.get('numGames', 10)
deck_name = body.get('deckName', 'My Deck')
record_logs = body.get('recordLogs', True)
if not decklist:
return JSONResponse({'error': 'decklist is required'}, status_code=400)
if num_games < 1 or num_games > 1000:
return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400)
sim_id = str(_uuid.uuid4())[:8]
with _sim_lock:
_sim_runs[sim_id] = {
'status': 'queued',
'completed': 0,
'total': num_games,
'deckName': deck_name,
'result': None,
'error': None,
}
t = _threading.Thread(target=_run_sim_thread, args=(sim_id, decklist, num_games, deck_name, record_logs), daemon=True)
t.start()
return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games})
@router.get('/api/sim/status')
async def sim_status(simId: str):
"""Poll simulation progress."""
with _sim_lock:
run = _sim_runs.get(simId)
if not run:
return JSONResponse({'error': 'sim not found'}, status_code=404)
return JSONResponse({
'simId': simId,
'status': run['status'],
'completed': run['completed'],
'total': run['total'],
'deckName': run.get('deckName', ''),
'error': run.get('error'),
})
@router.get('/api/sim/result')
async def sim_result(simId: str):
"""Get completed simulation results."""
with _sim_lock:
run = _sim_runs.get(simId)
if not run:
return JSONResponse({'error': 'sim not found'}, status_code=404)
if run['status'] != 'complete':
return JSONResponse({'error': 'sim not complete', 'status': run['status']}, status_code=400)
return JSONResponse(run['result'])
@router.post('/api/sim/run-from-deck')
async def sim_run_from_deck(request: FastAPIRequest):
"""Start simulation using a deck from the Deck Builder (by deck ID)."""
body = await request.json()
deck_id = body.get('deckId')
num_games = body.get('numGames', 10)
record_logs = body.get('recordLogs', True)
if not deck_id:
return JSONResponse({'error': 'deckId required'}, status_code=400)
conn = _get_db_conn()
cur = conn.cursor()
cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,))
row = cur.fetchone()
if not row:
return JSONResponse({'error': 'deck not found'}, status_code=404)
deck_name = row[0]
# Pull full card data from collection join so the sim engine has real types/stats
cur.execute("""
SELECT dc.card_name, dc.quantity,
ce.type_line, ce.cmc, ce.power, ce.toughness,
ce.oracle_text, ce.keywords, ce.mana_cost
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, cmc, power, toughness,
oracle_text, keywords, mana_cost
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
WHERE dc.deck_id = ?
""", (deck_id,))
card_data = []
for r in cur.fetchall():
for _ in range(r[1] or 1):
card_data.append({
'name': r[0],
'type_line': r[2] or '',
'cmc': r[3] or 0,
'power': r[4] or '',
'toughness': r[5] or '',
'oracle_text': r[6] or '',
'keywords': r[7] or '',
'mana_cost': r[8] or '',
})
if not card_data:
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
sim_id = str(_uuid.uuid4())[:8]
with _sim_lock:
_sim_runs[sim_id] = {
'status': 'queued',
'completed': 0,
'total': num_games,
'deckName': deck_name,
'result': None,
'error': None,
}
t = _threading.Thread(target=_run_sim_thread_v2, args=(sim_id, card_data, num_games, deck_name, record_logs), daemon=True)
t.start()
return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games, 'deckName': deck_name})
# ══════════════════════════════════════════════════════════════
# DeepSeek AI Opponent Brain
# ══════════════════════════════════════════════════════════════
@router.post('/api/deepseek/connect')
async def deepseek_connect(request: FastAPIRequest):
"""Test connection to the DeepSeek LLM endpoint and auto-detect model."""
body = await request.json() if await request.body() else {}
api_base = body.get('apiBase', None)
model = body.get('model', None)
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'error': 'DeepSeek brain failed to initialize'}, status_code=500)
# Allow runtime reconfiguration
if api_base:
brain.config.api_base = api_base
if model:
brain.config.model = model
connected = brain.check_connection()
return JSONResponse({
'connected': connected,
'apiBase': brain.config.api_base,
'model': brain.config.model,
'stats': brain.get_stats(),
})
@router.get('/api/deepseek/status')
async def deepseek_status():
"""Get DeepSeek brain status and performance stats."""
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'connected': False, 'error': 'Brain not initialized'})
return JSONResponse(brain.get_stats())
@router.post('/api/deepseek/configure')
async def deepseek_configure(request: FastAPIRequest):
"""Update DeepSeek configuration at runtime."""
body = await request.json()
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'error': 'Brain not initialized'}, status_code=500)
if 'apiBase' in body:
brain.config.api_base = body['apiBase']
if 'model' in body:
brain.config.model = body['model']
if 'temperature' in body:
brain.config.temperature = float(body['temperature'])
if 'maxTokens' in body:
brain.config.max_tokens = int(body['maxTokens'])
if 'timeout' in body:
brain.config.request_timeout = float(body['timeout'])
if 'cacheEnabled' in body:
brain.config.cache_enabled = bool(body['cacheEnabled'])
if 'logDecisions' in body:
brain.config.log_decisions = bool(body['logDecisions'])
if 'fallbackOnTimeout' in body:
brain.config.fallback_on_timeout = bool(body['fallbackOnTimeout'])
# Re-test connection with new settings
connected = brain.check_connection()
return JSONResponse({
'connected': connected,
'config': {
'apiBase': brain.config.api_base,
'model': brain.config.model,
'temperature': brain.config.temperature,
'maxTokens': brain.config.max_tokens,
'timeout': brain.config.request_timeout,
'cacheEnabled': brain.config.cache_enabled,
'logDecisions': brain.config.log_decisions,
'fallbackOnTimeout': brain.config.fallback_on_timeout,
},
})
@router.get('/api/deepseek/logs')
async def deepseek_logs():
"""Get decision log stats and flush pending entries."""
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'error': 'Brain not initialized'}, status_code=500)
pending = len(brain._decision_log)
flushed_path = None
if pending > 0:
try:
flushed_path = brain.flush_log()
except Exception as e:
return JSONResponse({'error': f'Failed to flush: {e}'}, status_code=500)
# List existing log files
import glob as _glob
log_dir = brain.config.log_dir
log_files = []
if log_dir and os.path.isdir(log_dir):
for f in sorted(_glob.glob(os.path.join(log_dir, 'decisions_*.jsonl'))):
stat = os.stat(f)
log_files.append({
'filename': os.path.basename(f),
'size_bytes': stat.st_size,
'modified': stat.st_mtime,
})
return JSONResponse({
'flushed': pending,
'flushedPath': flushed_path,
'logFiles': log_files[-20:], # last 20
})
def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict], num_games: int, deck_name: str, record_logs: bool):
"""Background thread for simulations using DeepSeek AI opponent."""
try:
import sys, os
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src')
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.lab.experiments import _generate_training_deck
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
# Build player's deck with real card data
deck_a = []
for cd in card_data:
c = Card(name=cd['name'])
if cd.get('type_line'):
c.type_line = cd['type_line']
if cd.get('cmc'):
c.cmc = float(cd['cmc'])
if cd.get('power') and cd.get('toughness'):
c.power = str(cd['power'])
c.toughness = str(cd['toughness'])
c.pt = c.power + '/' + c.toughness
if cd.get('oracle_text'):
c.oracle_text = cd['oracle_text']
if cd.get('mana_cost'):
c.mana_cost = cd['mana_cost']
if cd.get('keywords'):
kw = cd['keywords']
if isinstance(kw, str):
try:
import json as _json
kw = _json.loads(kw)
except Exception:
kw = []
if isinstance(kw, list):
c.keywords = kw
enrich_card(c)
deck_a.append(c)
deck_b = _generate_training_deck()
# Get DeepSeek brain from shared canonical source
brain = _get_deepseek_brain()
if brain and not brain._connected:
brain.check_connection()
engine = DeepSeekGameEngine(
brain=brain,
ai_player_index=1, # Training deck is player B (index 1)
max_turns=25,
record_log=record_logs,
)
import time
start = time.time()
wins = 0
losses = 0
total_turns = 0
total_damage_dealt = 0
total_damage_received = 0
total_spells_cast = 0
total_creatures_played = 0
total_removal_used = 0
total_ramp_played = 0
total_cards_drawn = 0
total_max_board = 0
game_results = []
for i in range(num_games):
game_id = f'ds-sim-{sim_id[:8]}-g{i+1}'
result = engine.run(deck_a, deck_b, name_a=deck_name, name_b='DeepSeek AI',
game_id=game_id, archetype='midrange')
game_data = result.to_dict()
game_data['gameNumber'] = i + 1
game_results.append(game_data)
if result.winner == 0:
wins += 1
else:
losses += 1
total_turns += result.turns
if result.player_a_stats:
total_damage_dealt += result.player_a_stats.damage_dealt
total_damage_received += result.player_a_stats.damage_received
total_spells_cast += result.player_a_stats.spells_cast
total_creatures_played += result.player_a_stats.creatures_played
total_removal_used += result.player_a_stats.removal_used
total_ramp_played += result.player_a_stats.ramp_played
total_cards_drawn += result.player_a_stats.cards_drawn
total_max_board += result.player_a_stats.max_board_size
with _sim_lock:
_sim_runs[sim_id]['completed'] = i + 1
elapsed = time.time() - start
n = num_games
# Write ML decision JSONL for training pipeline
ml_decisions = engine.flush_ml_decisions()
if ml_decisions:
ml_jsonl_path = os.path.join('results', f'ml-decisions-sim-{sim_id[:8]}.jsonl')
os.makedirs('results', exist_ok=True)
with open(ml_jsonl_path, 'w', encoding='utf-8') as mf:
import json as _mljson
for dec in ml_decisions:
mf.write(_mljson.dumps(dec) + '\n')
# Get DeepSeek stats for the summary
ds_stats = brain.get_stats() if brain else {}
summary = {
'deckName': deck_name,
'opponentName': 'DeepSeek AI',
'opponentType': 'deepseek',
'totalGames': n,
'wins': wins,
'losses': losses,
'winRate': round(wins / n * 100, 1) if n > 0 else 0.0,
'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0,
'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0,
'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0,
'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0,
'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0,
'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0,
'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0,
'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0,
'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0,
'elapsedSeconds': round(elapsed, 3),
'deepseekStats': ds_stats,
}
with _sim_lock:
_sim_runs[sim_id]['status'] = 'complete'
_sim_runs[sim_id]['result'] = {
'summary': summary,
'games': game_results,
}
except Exception as e:
import traceback
with _sim_lock:
_sim_runs[sim_id]['status'] = 'error'
_sim_runs[sim_id]['error'] = str(e)
traceback.print_exc()
@router.post('/api/sim/run-deepseek')
async def sim_run_deepseek(request: FastAPIRequest):
"""Start simulation using DeepSeek AI as the opponent brain."""
body = await request.json()
deck_id = body.get('deckId')
num_games = body.get('numGames', 5) # Default 5 (LLM is slower)
record_logs = body.get('recordLogs', True)
if not deck_id:
return JSONResponse({'error': 'deckId required'}, status_code=400)
if num_games < 1 or num_games > 50:
return JSONResponse({'error': 'numGames must be 1-50 for DeepSeek mode'}, status_code=400)
conn = _get_db_conn()
cur = conn.cursor()
cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,))
row = cur.fetchone()
if not row:
return JSONResponse({'error': 'deck not found'}, status_code=404)
deck_name = row[0]
cur.execute("""
SELECT dc.card_name, dc.quantity,
ce.type_line, ce.cmc, ce.power, ce.toughness,
ce.oracle_text, ce.keywords, ce.mana_cost
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, cmc, power, toughness,
oracle_text, keywords, mana_cost
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
WHERE dc.deck_id = ?
""", (deck_id,))
card_data = []
for r in cur.fetchall():
for _ in range(r[1] or 1):
card_data.append({
'name': r[0],
'type_line': r[2] or '',
'cmc': r[3] or 0,
'power': r[4] or '',
'toughness': r[5] or '',
'oracle_text': r[6] or '',
'keywords': r[7] or '',
'mana_cost': r[8] or '',
})
if not card_data:
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
sim_id = str(_uuid.uuid4())[:8]
with _sim_lock:
_sim_runs[sim_id] = {
'status': 'queued',
'completed': 0,
'total': num_games,
'deckName': deck_name,
'result': None,
'error': None,
}
t = _threading.Thread(
target=_run_sim_thread_deepseek,
args=(sim_id, card_data, num_games, deck_name, record_logs),
daemon=True,
)
t.start()
return JSONResponse({
'simId': sim_id,
'status': 'queued',
'total': num_games,
'deckName': deck_name,
'opponentType': 'deepseek',
})
-127
View File
@@ -1,127 +0,0 @@
"""
routes/import_routes.py
=======================
Deck import & commander meta endpoints:
POST /api/lab/import/url
POST /api/lab/import/text
GET /api/lab/meta/commanders
GET /api/lab/meta/search
POST /api/lab/meta/fetch
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
import routes.shared as _shared
from routes.shared import (
ImportUrlRequest,
ImportTextRequest,
MetaFetchRequest,
_import_from_url,
_parse_text_decklist,
_save_profile_to_dck,
_fetch_edhrec_average,
log,
)
router = APIRouter(tags=["import"])
@router.post("/api/lab/import/url")
async def import_from_url(req: ImportUrlRequest):
"""Import a deck from Archidekt or EDHREC URL. Returns parsed DeckProfile + saves .dck file."""
url = req.url.strip()
try:
profile = _import_from_url(url)
dck_path = _save_profile_to_dck(profile)
return {
"success": True,
"deckName": profile["name"],
"commander": profile["commander"],
"source": profile["source"],
"sourceUrl": profile["sourceUrl"],
"totalCards": profile["totalCards"],
"dckFile": dck_path.stem,
"colorIdentity": profile.get("colorIdentity", []),
"archetype": profile.get("archetype"),
}
except Exception as e:
raise HTTPException(400, str(e))
@router.post("/api/lab/import/text")
async def import_from_text(req: ImportTextRequest):
"""Import a deck from plain text card list."""
try:
profile = _parse_text_decklist(req.text, req.commander)
dck_path = _save_profile_to_dck(profile)
return {
"success": True,
"deckName": profile["name"],
"commander": profile["commander"],
"source": "Text Import",
"totalCards": profile["totalCards"],
"dckFile": dck_path.stem,
}
except Exception as e:
raise HTTPException(400, str(e))
@router.get("/api/lab/meta/commanders")
async def list_meta_commanders():
"""List available commanders in the meta mapping."""
commanders = []
for name, entries in _shared.COMMANDER_META.items():
entry = entries[0] if entries else {}
commanders.append({
"name": name,
"archetype": entry.get("archetype", ""),
"colorIdentity": entry.get("colorIdentity", []),
"source": entry.get("source", "edhrec"),
})
return {"commanders": commanders}
@router.get("/api/lab/meta/search")
async def search_meta_commanders(q: str = ""):
"""Search commanders by partial name match."""
query = q.lower()
matches = []
for name, entries in _shared.COMMANDER_META.items():
if query in name.lower():
entry = entries[0] if entries else {}
matches.append({
"name": name,
"archetype": entry.get("archetype", ""),
"colorIdentity": entry.get("colorIdentity", []),
})
return {"results": matches}
@router.post("/api/lab/meta/fetch")
async def fetch_meta_deck(req: MetaFetchRequest):
"""Fetch EDHREC average deck for a commander and save as .dck file."""
try:
profile = _fetch_edhrec_average(req.commander)
dck_path = _save_profile_to_dck(profile)
return {
"success": True,
"deckName": profile["name"],
"commander": profile["commander"],
"source": "EDHREC Average",
"sourceUrl": profile.get("sourceUrl", ""),
"totalCards": profile["totalCards"],
"dckFile": dck_path.stem,
"colorIdentity": profile.get("colorIdentity", []),
"sampleSize": profile.get("sampleSize"),
}
except Exception as e:
raise HTTPException(400, f"Failed to fetch meta deck for '{req.commander}': {str(e)}")
# ══════════════════════════════════════════════════════════════
# Existing Endpoints (v1/v2)
# ══════════════════════════════════════════════════════════════
-445
View File
@@ -1,445 +0,0 @@
"""
routes/lab.py
=============
Batch simulation & lab management endpoints:
POST /api/lab/start
POST /api/lab/start-deepseek
GET /api/lab/status
GET /api/lab/result
GET /api/lab/decks
GET /api/lab/history
GET /api/lab/profiles
GET /api/lab/profiles/{name}
GET /api/lab/analytics/{deck_name}
GET /api/lab/trends/{deck_name}
GET /api/lab/log
GET /api/lab/debug-log
"""
from __future__ import annotations
import glob
import json
import os
import re
import threading
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request as FastAPIRequest
from fastapi.responses import JSONResponse
from routes.shared import (
CFG,
active_batches,
BatchState,
StartRequest,
StartResponse,
StatusResponse,
AI_PROFILES,
PRECON_DIR,
PRECON_INDEX,
build_java_command,
run_batch_subprocess,
_run_deepseek_batch_thread,
parse_dck_file,
_get_db_conn,
_load_deck_cards_by_name,
_ml_logging_enabled,
log,
log_batch,
)
router = APIRouter(tags=["lab"])
@router.post("/api/lab/start", response_model=StartResponse)
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
"""Start a new batch simulation run."""
if len(req.decks) < 2 or len(req.decks) > 4:
raise HTTPException(400, "2-4 decks required")
if not all(req.decks):
raise HTTPException(400, f"All {len(req.decks)} deck slots must be filled")
if req.numGames < 1 or req.numGames > 10000:
raise HTTPException(400, "numGames must be 1-10000")
if req.threads < 1 or req.threads > 16:
raise HTTPException(400, "threads must be 1-16")
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):
raise HTTPException(500, f"Forge dir not found: {CFG.forge_dir}")
batch_id = str(uuid.uuid4())[:12]
state = BatchState(batch_id, req.numGames, req.threads)
active_batches[batch_id] = state
os.makedirs(CFG.results_dir, exist_ok=True)
output_path = os.path.join(CFG.results_dir, f"batch-{batch_id}.json")
state.result_path = output_path
background_tasks.add_task(
run_batch_subprocess,
state,
req.decks,
req.numGames,
req.threads,
req.seed,
req.clock,
output_path,
req.useLearnedPolicy,
req.policyStyle,
req.policyGreedy,
req.aiSimplified,
req.aiThinkTimeMs,
req.maxQueueDepth,
)
policy_msg = ""
if req.useLearnedPolicy:
policy_msg = f" [Learned Policy: {req.policyStyle}]"
return StartResponse(
batchId=batch_id,
status="started",
message=f"Running {req.numGames} games with {req.threads} threads{policy_msg}",
)
@router.get("/api/lab/status", response_model=StatusResponse)
async def get_status(batchId: Optional[str] = None):
# If no batchId given, find the most recent active (or last) batch
state = None
if batchId:
state = active_batches.get(batchId)
else:
# Return the most recently active batch, or any batch
for s in active_batches.values():
if s.running:
state = s
break
if state is None and active_batches:
state = list(active_batches.values())[-1]
if not state:
return StatusResponse(
batchId="", running=False, completed=0, total=0,
threads=0, elapsedMs=0, error=None, simsPerSec=0.0,
run_id="", games_completed=0, total_games=0, current_decks=[],
)
elapsed = int((datetime.now() - state.start_time).total_seconds() * 1000)
deck_names = getattr(state, 'deck_names', []) if hasattr(state, 'deck_names') else []
return StatusResponse(
batchId=state.batch_id,
running=state.running,
completed=state.completed_games,
total=state.total_games,
threads=state.threads,
elapsedMs=elapsed if state.running else state.elapsed_ms,
error=state.error,
simsPerSec=state.sims_per_sec,
# React SPA fields
run_id=state.batch_id,
games_completed=state.completed_games,
total_games=state.total_games,
current_decks=deck_names,
)
@router.get("/api/lab/result")
async def get_result(batchId: Optional[str] = None):
state = None
if batchId:
state = active_batches.get(batchId)
else:
# Find most recent completed batch
for s in reversed(list(active_batches.values())):
if not s.running and s.result_path:
state = s
break
if not state:
raise HTTPException(404, "No batch result available")
if state.running:
raise HTTPException(409, "Batch still running")
if state.error:
raise HTTPException(500, state.error)
if not state.result_path or not os.path.exists(state.result_path):
raise HTTPException(500, "Result file not found")
with open(state.result_path, "r", encoding="utf-8") as f:
data = json.load(f)
return JSONResponse(content=data)
@router.get("/api/lab/decks")
async def list_decks():
decks = []
seen_names = set()
# 1. Forge .dck files from decks directory
decks_dir = CFG.forge_decks_dir
if decks_dir and os.path.isdir(decks_dir):
for f in sorted(Path(decks_dir).glob("*.dck")):
deck_name = f.stem
decks.append({"name": deck_name, "filename": f.name, "source": "forge"})
seen_names.add(deck_name.lower())
# 2. Decks from the Deck Builder database
try:
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, name, commander_name FROM decks ORDER BY id DESC"
).fetchall()
for row in rows:
db_name = row["name"]
if db_name.lower() not in seen_names:
decks.append({
"name": db_name,
"filename": "",
"source": "deckbuilder",
"deck_id": row["id"],
"commander": row["commander_name"] or "",
})
seen_names.add(db_name.lower())
except Exception as e:
log.warning(f" WARNING: Failed to load DB decks for /api/lab/decks: {e}")
return {"decks": decks}
@router.get("/api/lab/history")
async def list_history():
results_dir = Path(CFG.results_dir)
if not results_dir.exists():
return {"results": []}
results = []
for f in sorted(results_dir.glob("batch-*.json"), reverse=True):
try:
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
meta = data.get("metadata", {})
decks = data.get("decks", [])
results.append({
"filename": f.name,
"batchId": meta.get("batchId", ""),
"timestamp": meta.get("timestamp", ""),
"totalGames": meta.get("completedGames", 0),
"threads": meta.get("threads", 1),
"elapsedMs": meta.get("elapsedMs", 0),
"decks": [{"name": d.get("deckName", ""), "source": d.get("source", "")} for d in decks],
})
except Exception:
continue
return {"results": results[:50]}
# ════════════════════════════════════════════════════════════
# AI Profiles
# ════════════════════════════════════════════════════════════
AI_PROFILES = {
"default": {
"name": "default",
"description": "Balanced \u2014 Forge's default AI behavior",
"aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5,
"boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5,
},
"aggro": {
"name": "aggro",
"description": "Aggressive \u2014 attacks early, prioritizes damage",
"aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3,
"boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1,
},
"control": {
"name": "control",
"description": "Control \u2014 defensive, removal-heavy, card advantage",
"aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9,
"boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9,
},
"combo": {
"name": "combo",
"description": "Combo \u2014 ramps, digs for pieces, assembles combos",
"aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4,
"boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7,
},
"midrange": {
"name": "midrange",
"description": "Midrange \u2014 flexible, strong board presence, value-oriented",
"aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6,
"boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5,
},
}
@router.get("/api/lab/profiles")
async def list_profiles():
return {"profiles": list(AI_PROFILES.values())}
@router.get("/api/lab/profiles/{name}")
async def get_profile(name: str):
profile = AI_PROFILES.get(name.lower())
if not profile:
raise HTTPException(404, f"Profile '{name}' not found. Available: {list(AI_PROFILES.keys())}")
return profile
@router.get("/api/lab/analytics/{deck_name}")
async def analyze_deck(deck_name: str):
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
raise HTTPException(500, f"Decks directory not found: {decks_dir}")
deck_path = os.path.join(decks_dir, deck_name + ".dck")
if not os.path.exists(deck_path):
for f in os.listdir(decks_dir):
if f.lower() == deck_name.lower() + ".dck":
deck_path = os.path.join(decks_dir, f)
break
else:
raise HTTPException(404, f"Deck not found: {deck_name}")
try:
analysis = parse_dck_file(deck_path)
return analysis
except Exception as e:
raise HTTPException(500, f"Analysis failed: {str(e)}")
def parse_dck_file(deck_path: str) -> dict:
cards = []
commander = None
section = "Main"
deck_name = Path(deck_path).stem
with open(deck_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("//"):
continue
if line.startswith("[") and line.endswith("]"):
section = line[1:-1]
continue
if line.startswith("Name="):
deck_name = line[5:].strip()
continue
m = re.match(r"^(\d+)\s+(.+?)(?:\|(.+))?$", line)
if m:
qty = int(m.group(1))
name = m.group(2).strip()
set_code = m.group(3).strip() if m.group(3) else ""
cards.append({
"quantity": qty, "name": name, "set": set_code,
"section": section,
"is_commander": 1 if section == "Commander" else 0,
})
if section == "Commander":
commander = name
total = sum(c["quantity"] for c in cards)
return {
"deckName": deck_name,
"commanderName": commander,
"totalCards": total,
"cardCount": len(cards),
"cards": cards[:200],
}
@router.get("/api/lab/trends/{deck_name}")
async def get_deck_trends(deck_name: str):
results_dir = Path(CFG.results_dir)
if not results_dir.exists():
return {"deckName": deck_name, "history": []}
history = []
for f in sorted(results_dir.glob("batch-*.json")):
try:
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
summary = data.get("summary", {})
meta = data.get("metadata", {})
per_deck = summary.get("perDeck", [])
for ds in per_deck:
if ds.get("deckName", "").lower() == deck_name.lower():
history.append({
"batchId": meta.get("batchId"),
"timestamp": meta.get("timestamp"),
"winRate": ds.get("winRate", 0),
"wins": ds.get("wins", 0),
"losses": ds.get("losses", 0),
"draws": ds.get("draws", 0),
"totalGames": meta.get("completedGames", 0),
})
break
except Exception:
continue
return {"deckName": deck_name, "history": history}
@router.get("/api/lab/log")
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:]}
@router.get("/api/lab/debug-log")
async def get_debug_log():
"""Return the raw Forge subprocess debug log for diagnosing simulation issues."""
# Check multiple possible locations for the debug log
candidates = []
if CFG.forge_dir:
candidates.append(Path(CFG.forge_dir).parent / "forge-sim-debug.log")
candidates.append(Path(CFG.forge_dir) / "forge-sim-debug.log")
candidates.append(Path("forge-sim-debug.log"))
for log_path in candidates:
if log_path.exists():
text = log_path.read_text(encoding="utf-8", errors="replace")
# Return last 50KB to avoid huge responses
if len(text) > 50000:
text = "... (truncated) ...\n" + text[-50000:]
return {"path": str(log_path), "content": text}
return {"path": None, "content": "No debug log found. Run a simulation first."}
@router.post('/api/lab/start-deepseek')
async def start_batch_deepseek(request: FastAPIRequest, background_tasks: BackgroundTasks):
"""Start a batch simulation using the Python sim engine + DeepSeek LLM opponent."""
body = await request.json()
decks = body.get('decks', [])
num_games = body.get('numGames', 30)
# Note: threads not applicable for Python sim (single-threaded LLM calls)
if not decks or len(decks) < 1:
raise HTTPException(400, 'At least 1 deck required')
if num_games < 1 or num_games > 500:
raise HTTPException(400, 'numGames must be 1-500')
# Filter empty deck slots
decks = [d for d in decks if d]
if not decks:
raise HTTPException(400, 'No valid decks provided')
batch_id = str(uuid.uuid4())[:12]
state = BatchState(batch_id, num_games, 1)
active_batches[batch_id] = state
os.makedirs(CFG.results_dir, exist_ok=True)
output_path = os.path.join(CFG.results_dir, f'batch-{batch_id}.json')
state.result_path = output_path
# Use threading instead of BackgroundTasks to avoid async issues with DB
t = threading.Thread(
target=_run_deepseek_batch_thread,
args=(state, decks, num_games, output_path),
daemon=True,
)
t.start()
return JSONResponse({
'batchId': batch_id,
'status': 'started',
'message': f'Running {num_games} games across {len(decks)} decks with DeepSeek AI',
'engine': 'deepseek',
})
# Global ML logging toggle (can be enabled via API)
_ml_logging_enabled = False
-95
View File
@@ -1,95 +0,0 @@
"""
routes/precon.py
================
Precon deck management endpoints:
GET /api/lab/precons
POST /api/lab/precons/install
POST /api/lab/precons/install-batch
POST /api/lab/precons/refresh
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
from fastapi import APIRouter, HTTPException
import routes.shared as _shared # module reference, not value import
from routes.shared import CFG, PRECON_DIR, download_precon_database, log
router = APIRouter(tags=["precon"])
@router.get("/api/lab/precons")
async def list_precons():
"""List all available precon decks."""
return {"precons": _shared.PRECON_INDEX} # reads current value every call
@router.post("/api/lab/precons/install")
async def install_precon(req: dict):
"""Install a precon deck to the Forge decks directory.
Body: {"fileName": "Elven_Empire.dck"}
"""
file_name = req.get("fileName", "")
if not file_name:
raise HTTPException(400, "fileName is required")
src = PRECON_DIR / file_name
if not src.exists():
raise HTTPException(404, f"Precon not found: {file_name}")
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
dst = Path(decks_dir) / file_name
shutil.copy2(str(src), str(dst))
deck_name = file_name.replace(".dck", "")
return {
"installed": True,
"deckName": deck_name,
"destination": str(dst),
"message": f"Installed {deck_name} to Forge decks",
}
@router.post("/api/lab/precons/install-batch")
async def install_precons_batch(req: dict):
"""Install multiple precon decks at once.
Body: {"fileNames": ["Elven_Empire.dck", "Necron_Dynasties.dck"]}
"""
file_names = req.get("fileNames", [])
if not file_names:
raise HTTPException(400, "fileNames list is required")
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
results = []
for file_name in file_names:
src = PRECON_DIR / file_name
if not src.exists():
results.append({"fileName": file_name, "installed": False, "error": "not found"})
continue
dst = Path(decks_dir) / file_name
shutil.copy2(str(src), str(dst))
deck_name = file_name.replace(".dck", "")
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
return {"results": results}
@router.post("/api/lab/precons/refresh")
async def refresh_precons():
"""Force re-download of all Commander precon decks from GitHub."""
result = download_precon_database(force=True)
if result.get("error"):
raise HTTPException(502, result["error"])
return {
"message": f"Downloaded {result['downloaded']} Commander precon decks",
"total": result.get("total", 0),
}
-481
View File
@@ -1,481 +0,0 @@
"""
routes/scanner.py
=================
Card scanner & collection enrichment endpoints:
POST /api/collection/scan
POST /api/collection/scan/add
POST /api/collection/re-enrich
POST /api/collection/auto-classify
POST /api/collection/auto-classify-all
GET /api/collection/{cardId}/edhrec
"""
from __future__ import annotations
import json
import os
import re
import time
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import URLError
from fastapi import APIRouter, HTTPException
from fastapi import Request as FastAPIRequest
from routes.shared import (
CFG, log_scan, log_deckgen,
_get_db_conn, _row_to_dict, _enrich_from_scryfall,
_scryfall_fuzzy_lookup, _detect_card_roles,
_to_edhrec_slug, _API_HEADERS,
_edhrec_cache_get, _edhrec_cache_set,
)
router = APIRouter(tags=["scanner"])
@router.post("/api/collection/scan")
async def scan_card_image(request: FastAPIRequest):
"""
Scan a card image and return recognized card name(s).
Uses Ximilar Visual AI for card recognition.
Accepts multipart/form-data with:
- file: image file (JPEG/PNG)
- mode: "single" (default) or "multi"
Returns:
{ "results": [ { raw_ocr, matched_name, set_code, scryfall_id, confidence, image_uri, error, collector_number, rarity, tcgplayer_url }, ... ] }
"""
try:
from scanner.pipeline import scan_single, scan_multi
except ImportError as e:
raise HTTPException(500, f"Scanner module not available: {e}")
# Try CFG first, then fall back to env var
api_key = CFG.ximilar_api_key or os.environ.get("XIMILAR_API_KEY", "")
if not api_key:
raise HTTPException(500, "Card scanner not configured. Start the server with --ximilar-key YOUR_KEY or set XIMILAR_API_KEY env var.")
# Parse multipart form
form = await request.form()
upload = form.get("file")
if upload is None:
raise HTTPException(400, "No file uploaded. Send an image as 'file' in multipart/form-data.")
mode = str(form.get("mode", "single")).lower()
image_bytes = await upload.read()
if len(image_bytes) == 0:
raise HTTPException(400, "Uploaded file is empty")
# Limit file size (20 MB)
if len(image_bytes) > 20 * 1024 * 1024:
raise HTTPException(400, "Image file too large (max 20 MB)")
log_scan.info(f" Processing {len(image_bytes)} bytes, mode={mode} (Ximilar AI)")
if mode == "multi":
results = scan_multi(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key)
else:
result = scan_single(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key)
results = [result]
return {
"results": [r.to_dict() for r in results]
}
@router.post("/api/collection/scan/add")
async def scan_add_cards(body: dict):
"""
Add scanned cards to the collection.
Body:
{
"cards": [
{ "name": "Lightning Bolt", "set_code": "m11", "quantity": 1 },
...
]
}
"""
cards = body.get("cards", [])
if not cards:
raise HTTPException(400, "No cards provided")
conn = _get_db_conn()
imported = 0
updated = 0
errors = []
for card_req in cards:
name = str(card_req.get("name", "")).strip()
if not name:
continue
quantity = int(card_req.get("quantity", 1))
set_code = str(card_req.get("set_code", "")).lower()
collector_number = str(card_req.get("collector_number", ""))
finish = "NORMAL"
try:
enriched = _enrich_from_scryfall(name, set_code, collector_number)
except Exception as e:
errors.append(f"Scryfall error for '{name}': {e}")
continue
if not enriched or "_error" in enriched:
err_msg = enriched.get("_error", f"Not found: '{name}'") if enriched else f"Not found: '{name}'"
errors.append(err_msg)
continue
resolved_name = enriched.get("name", name)
# Check for existing entry
existing = conn.execute(
"""SELECT id, quantity FROM collection_entries
WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""",
(resolved_name, set_code, collector_number, finish),
).fetchone()
if existing:
new_qty = existing["quantity"] + quantity
conn.execute(
"UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?",
(new_qty, existing["id"]),
)
conn.commit()
updated += 1
else:
conn.execute(
"""INSERT INTO collection_entries
(name, type_line, subtypes, is_legendary, is_basic, color_identity,
cmc, mana_cost, oracle_text, keywords,
power, toughness, rarity, set_name, edhrec_rank,
tcg_price, salt_score, is_game_changer,
category, scryfall_id, tcgplayer_id,
quantity, finish, condition, language, notes, tags,
set_code, collector_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
resolved_name,
enriched.get("type_line", ""),
enriched.get("subtypes", "[]"),
enriched.get("is_legendary", 0),
enriched.get("is_basic", 0),
enriched.get("color_identity", "[]"),
enriched.get("cmc", 0.0),
enriched.get("mana_cost", ""),
enriched.get("oracle_text", ""),
enriched.get("keywords", "[]"),
enriched.get("power", ""),
enriched.get("toughness", ""),
enriched.get("rarity", ""),
enriched.get("set_name", ""),
enriched.get("edhrec_rank", 0),
enriched.get("tcg_price", 0.0),
enriched.get("salt_score", 0.0),
enriched.get("is_game_changer", 0),
enriched.get("category", "[]"),
enriched.get("scryfall_id", ""),
enriched.get("tcgplayer_id", ""),
quantity,
finish,
"", # condition
"", # language
"", # notes
"", # tags
set_code,
collector_number,
),
)
conn.commit()
imported += 1
return {
"importedCount": imported,
"updatedCount": updated,
"failedCount": len(errors),
"errors": errors,
}
# ══════════════════════════════════════════════════════════════
# Re-enrich Collection from Scryfall
# ══════════════════════════════════════════════════════════════
@router.post("/api/collection/re-enrich")
async def re_enrich_collection():
"""
Re-fetch Scryfall data for all collection entries to backfill
missing fields (mana_cost, power, toughness, rarity, set_name, edhrec_rank).
Also refreshes: type_line, oracle_text, keywords, cmc, color_identity, prices.
Runs synchronously — may take a while for large collections.
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, name, set_code, collector_number FROM collection_entries ORDER BY id"
).fetchall()
total = len(rows)
enriched_count = 0
skipped_count = 0
errors = []
for row in rows:
card_id = row["id"]
name = row["name"]
set_code = row["set_code"] or ""
collector_number = row["collector_number"] or ""
try:
data = _enrich_from_scryfall(name, set_code, collector_number)
except Exception as e:
errors.append(f"Error for '{name}': {e}")
skipped_count += 1
continue
if not data or "_error" in data:
err_msg = data.get("_error", f"Not found: '{name}'") if data else f"Not found: '{name}'"
errors.append(err_msg)
skipped_count += 1
continue
conn.execute(
"""UPDATE collection_entries SET
type_line = ?, subtypes = ?, is_legendary = ?, is_basic = ?,
color_identity = ?, cmc = ?, mana_cost = ?,
oracle_text = ?, keywords = ?,
power = ?, toughness = ?,
rarity = ?, set_name = ?, edhrec_rank = ?,
tcg_price = ?, scryfall_id = ?, tcgplayer_id = ?,
category = ?,
updated_at = datetime('now')
WHERE id = ?""",
(
data.get("type_line", ""),
data.get("subtypes", "[]"),
data.get("is_legendary", 0),
data.get("is_basic", 0),
data.get("color_identity", "[]"),
data.get("cmc", 0.0),
data.get("mana_cost", ""),
data.get("oracle_text", ""),
data.get("keywords", "[]"),
data.get("power", ""),
data.get("toughness", ""),
data.get("rarity", ""),
data.get("set_name", ""),
data.get("edhrec_rank", 0),
data.get("tcg_price", 0.0),
data.get("scryfall_id", ""),
data.get("tcgplayer_id", ""),
data.get("category", "[]"),
card_id,
),
)
conn.commit()
enriched_count += 1
return {
"total": total,
"enrichedCount": enriched_count,
"skippedCount": skipped_count,
"errors": errors[:50], # cap error list
}
# ══════════════════════════════════════════════════════════════
# Auto-Classify Collection
# ══════════════════════════════════════════════════════════════
@router.post("/api/collection/auto-classify")
async def auto_classify_collection():
"""
Run auto-classification on all collection entries.
Uses oracle_text, type_line, and keywords to detect functional roles
(Ramp, Draw, Removal, Board Wipe, Anthem, Stax, etc.).
Only updates cards whose current category is empty or '[]'.
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, oracle_text, type_line, keywords, category FROM collection_entries ORDER BY id"
).fetchall()
total = len(rows)
classified_count = 0
skipped_count = 0
for row in rows:
card_id = row["id"]
existing_cats = row["category"] or "[]"
# Parse existing categories
try:
cats = json.loads(existing_cats)
except Exception:
cats = []
# Skip cards that already have manually-set categories
if cats:
skipped_count += 1
continue
oracle_text = row["oracle_text"] or ""
type_line = row["type_line"] or ""
keywords = row["keywords"] or "[]"
roles = _detect_card_roles(oracle_text, type_line, keywords)
if roles:
conn.execute(
"UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?",
(json.dumps(roles), card_id),
)
classified_count += 1
conn.commit()
return {
"total": total,
"classifiedCount": classified_count,
"skippedCount": skipped_count,
"message": f"Classified {classified_count} cards, skipped {skipped_count} (already had categories)",
}
@router.post("/api/collection/auto-classify-all")
async def auto_classify_all_collection():
"""
Force re-classify ALL collection entries, overwriting existing categories.
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, oracle_text, type_line, keywords FROM collection_entries ORDER BY id"
).fetchall()
total = len(rows)
classified_count = 0
for row in rows:
card_id = row["id"]
oracle_text = row["oracle_text"] or ""
type_line = row["type_line"] or ""
keywords = row["keywords"] or "[]"
roles = _detect_card_roles(oracle_text, type_line, keywords)
conn.execute(
"UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?",
(json.dumps(roles), card_id),
)
classified_count += 1
conn.commit()
return {
"total": total,
"classifiedCount": classified_count,
"message": f"Re-classified all {classified_count} cards",
}
# ══════════════════════════════════════════════════════════════
# EDHREC Recommendations Cache + Endpoint
# ══════════════════════════════════════════════════════════════
@router.get("/api/collection/{cardId}/edhrec")
async def get_card_edhrec(cardId: int):
"""
Get EDHREC recommendations for a collection card.
For legendary creatures (potential commanders): fetch commander synergy data.
For other cards: fetch 'also played with' data.
"""
conn = _get_db_conn()
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
if not row:
raise HTTPException(404, f"Card with id {cardId} not found")
card = _row_to_dict(row)
name = card["name"]
type_line = card.get("type_line", "")
is_legendary = card.get("is_legendary", 0)
# Determine if it's a legendary creature (potential commander)
is_commander = bool(
is_legendary and
re.search(r"\bCreature\b", type_line, re.IGNORECASE)
)
slug = _to_edhrec_slug(name)
cache_key = f"edhrec:{'cmd' if is_commander else 'card'}:{slug}"
cached = _edhrec_cache_get(cache_key)
if cached:
return cached
recommendations = []
links = {
"edhrecPage": (
f"https://edhrec.com/commanders/{slug}"
if is_commander
else f"https://edhrec.com/cards/{slug}"
),
"archidektSearch": f"https://archidekt.com/search/cards?q={name.replace(' ', '+')}",
"moxfieldSearch": f"https://www.moxfield.com/search?q={name.replace(' ', '+')}",
}
try:
if is_commander:
url = f"https://json.edhrec.com/pages/commanders/{slug}.json"
else:
url = f"https://json.edhrec.com/pages/cards/{slug}.json"
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
if is_commander:
# Extract top cards from cardlists
container = data.get("container", {})
json_dict = container.get("json_dict", {})
for cardlist in json_dict.get("cardlists", []):
tag = cardlist.get("tag", "")
for cv in cardlist.get("cardviews", [])[:10]:
rec_name = cv.get("name", "")
if not rec_name:
continue
synergy = cv.get("synergy", 0.0)
recommendations.append({
"name": rec_name,
"synergy": synergy,
"role": tag or "Recommended",
})
else:
# Extract "also played with" cards
container = data.get("container", {})
json_dict = container.get("json_dict", {})
for cardlist in json_dict.get("cardlists", []):
tag = cardlist.get("tag", "")
for cv in cardlist.get("cardviews", [])[:10]:
rec_name = cv.get("name", "")
if not rec_name:
continue
synergy = cv.get("synergy", 0.0)
recommendations.append({
"name": rec_name,
"synergy": synergy,
"role": tag or "Also Played With",
})
except Exception as e:
log_deckgen.error(f" Error fetching data for '{name}': {e}")
# Graceful degradation — return empty results
result = {
"recommendations": recommendations[:30],
"links": links,
}
_edhrec_cache_set(cache_key, result)
return result
-1905
View File
File diff suppressed because it is too large. Load diff
+2 -6
View File
@@ -13,10 +13,6 @@ No local OCR or OpenCV needed — all recognition is done by the
Ximilar cloud API using visual AI.
"""
from typing import Callable, Optional
import logging
log = logging.getLogger("commander_ai_lab.scanner")
from .ximilar_client import identify_cards, XimilarResult
@@ -164,7 +160,7 @@ def _ximilar_to_scan_result(xr: XimilarResult,
try:
scryfall_data = scryfall_lookup(xr.name)
except Exception as e:
log.error(f" Scryfall lookup failed for '{xr.name}': {e}")
print(f" [SCAN] Scryfall lookup failed for '{xr.name}': {e}")
if scryfall_data and scryfall_data.get("object") != "error":
result.scryfall_id = scryfall_data.get("id", "")
@@ -185,6 +181,6 @@ def _ximilar_to_scan_result(xr: XimilarResult,
# Extract image from scryfall URL pattern
# e.g., https://scryfall.com/card/m11/149/lightning-bolt
result.image_uri = "" # Will be fetched by frontend via scryfall_id
log.info(f" No Scryfall match for '{xr.name}' — using Ximilar data only")
print(f" [SCAN] No Scryfall match for '{xr.name}' — using Ximilar data only")
return result
+4 -8
View File
@@ -13,10 +13,6 @@ import json
from typing import Optional, List
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
import logging
log = logging.getLogger("commander_ai_lab.scanner.ximilar")
XIMILAR_ENDPOINT = "https://api.ximilar.com/collectibles/v2/tcg_id"
@@ -86,7 +82,7 @@ def identify_cards(image_bytes: bytes, api_key: str,
)
try:
log.info(f" Sending {len(image_bytes):,} bytes to API (analyze_all={analyze_all})")
print(f" [XIMILAR] Sending {len(image_bytes):,} bytes to API (analyze_all={analyze_all})")
with urlopen(req, timeout=60) as resp:
resp_data = json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
@@ -172,12 +168,12 @@ def _parse_response(resp_data: dict) -> List[XimilarResult]:
if not results:
results.append(XimilarResult(error="Ximilar could not identify any cards"))
log.info(f" Identified {len(results)} card(s)")
print(f" [XIMILAR] Identified {len(results)} card(s)")
for r in results:
if r.name:
log.info(f" - {r.name} ({r.set_code}) conf={r.confidence:.2f}")
print(f" [XIMILAR] - {r.name} ({r.set_code}) conf={r.confidence:.2f}")
elif r.error:
log.error(f" - ERROR: {r.error}")
print(f" [XIMILAR] - ERROR: {r.error}")
return results
-14
View File
@@ -1,14 +0,0 @@
"""
Commander AI Lab — Pygame Prototype GUI (Phase 4)
"""
from commander_ai_lab.gui.constants import SCREEN_W, SCREEN_H, FPS
from commander_ai_lab.gui.board import BoardRenderer
from commander_ai_lab.gui.card_renderer import CardRenderer
from commander_ai_lab.gui.hud import HUD
from commander_ai_lab.gui.narration_panel import NarrationPanel
from commander_ai_lab.gui.game_window import GameWindow
__all__ = [
"SCREEN_W", "SCREEN_H", "FPS",
"BoardRenderer", "CardRenderer", "HUD", "NarrationPanel", "GameWindow",
]
-355
View File
@@ -1,355 +0,0 @@
"""
Commander AI Lab — Board Renderer (Phase 4)
============================================
Layouts and renders the 4-player split-screen board.
Layout (1280 x 960):
┌──────────────────────────────────────────────┐
│ Player 2 (top — opponent) │
├──────────┬───────────────────┬───────────────┤
│ │ STACK / CENTER │ │
│ Player 3 │ │ Player 1 │
│ (left) │ │ (right) │
├──────────┴───────────────────┴───────────────┤
│ Player 0 (bottom — human / seat 0) │
└──────────────────────────────────────────────┘
Each quadrant contains:
hand zone | battlefield (creatures / non-creatures) | graveyard | exile | command zone
The narration panel occupies the far right strip (270 px wide).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
try:
import pygame
_PYGAME_AVAILABLE = True
except ImportError:
_PYGAME_AVAILABLE = False
from commander_ai_lab.gui.constants import (
SCREEN_W, SCREEN_H,
CARD_W, CARD_H,
COLOUR_BG, COLOUR_ZONE_BG, COLOUR_ZONE_BORDER,
COLOUR_TEXT, COLOUR_TEXT_DIM, COLOUR_HIGHLIGHT,
COLOUR_ELIMINATED, COLOUR_THINKING,
SEAT_COLOURS,
NARRATION_X, NARRATION_W,
PHASE_BAR_H,
)
from commander_ai_lab.gui.card_renderer import CardRenderer
# Width available to the board (narration panel occupies the right strip)
_BOARD_W = NARRATION_X - 4
# Quadrant dimensions
_TOP_H = 220
_BOTTOM_H = 220
_MID_H = SCREEN_H - _TOP_H - _BOTTOM_H - PHASE_BAR_H
_SIDE_W = 200
_CENTER_W = _BOARD_W - 2 * _SIDE_W
@dataclass
class ZoneRect:
"""Pixel rectangle for a named zone."""
name: str
x: int
y: int
w: int
h: int
def contains(self, px: int, py: int) -> bool:
return self.x <= px < self.x + self.w and self.y <= py < self.y + self.h
@dataclass
class CardHit:
"""Result of a click hit-test: which card in which zone."""
seat: int
zone: str # 'hand', 'battlefield', 'graveyard', 'exile', 'command'
card_index: int # index in the list
card: dict
class BoardRenderer:
"""
Manages zone layout and renders the full 4-player board each frame.
Parameters
----------
card_renderer : CardRenderer
Shared card drawing helper.
human_seat : int
Seat whose hand is shown face-up (bottom quadrant).
"""
# Seat -> screen quadrant mapping
# seat 0 = bottom, 1 = right, 2 = top, 3 = left
_SEAT_QUADRANT = {0: "bottom", 1: "right", 2: "top", 3: "left"}
def __init__(
self,
card_renderer: CardRenderer,
human_seat: int = 0,
) -> None:
self.cr = card_renderer
self.human_seat = human_seat
# Highlighted card (legal target indicator)
self._highlighted: Optional[CardHit] = None
# Selected card in hand (waiting for target)
self._selected: Optional[CardHit] = None
# Zone rects computed once
self._zones: dict[int, dict[str, ZoneRect]] = {}
self._build_zones()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def render(
self,
surface,
game_state,
active_seat: int,
thinking_seats: set[int],
highlighted_cards: set[tuple] | None = None, # {(seat, zone, idx)}
) -> None:
"""
Render the full board onto surface for the current frame.
Args:
surface: Main pygame display surface.
game_state: CommanderGameState (or compatible stub).
active_seat: Seat whose turn it is (highlighted border).
thinking_seats: Set of seat indices currently running AI inference.
highlighted_cards: Cards to show with a highlight ring.
"""
if not _PYGAME_AVAILABLE:
return
highlighted_cards = highlighted_cards or set()
surface.fill(COLOUR_BG)
for seat, player in enumerate(game_state.players):
self._render_player_zones(
surface, seat, player, active_seat,
thinking_seats, highlighted_cards,
)
self._render_stack_zone(surface, game_state)
self._render_center_dividers(surface)
def hit_test(
self,
px: int,
py: int,
game_state,
) -> Optional[CardHit]:
"""
Return the CardHit under pixel (px, py), or None.
Used by the input handler to resolve clicks.
"""
for seat, zones in self._zones.items():
player = game_state.players[seat]
for zone_name, zone_rect in zones.items():
if not zone_rect.contains(px, py):
continue
cards = self._get_zone_cards(player, zone_name)
idx = self._card_index_at(px, py, zone_rect, cards)
if idx is not None:
return CardHit(
seat=seat,
zone=zone_name,
card_index=idx,
card=cards[idx],
)
return None
def set_highlighted(self, hit: Optional[CardHit]) -> None:
self._highlighted = hit
def set_selected(self, hit: Optional[CardHit]) -> None:
self._selected = hit
# ------------------------------------------------------------------
# Zone Layout
# ------------------------------------------------------------------
def _build_zones(self) -> None:
"""Pre-compute zone rects for all 4 seats."""
# seat 0: bottom quadrant
self._zones[0] = self._zones_for_bottom()
# seat 1: right quadrant
self._zones[1] = self._zones_for_right()
# seat 2: top quadrant
self._zones[2] = self._zones_for_top()
# seat 3: left quadrant
self._zones[3] = self._zones_for_left()
def _zones_for_bottom(self) -> dict[str, ZoneRect]:
y_bf = SCREEN_H - _BOTTOM_H - PHASE_BAR_H
y_hand = SCREEN_H - CARD_H - 20 - PHASE_BAR_H
return {
"battlefield": ZoneRect("battlefield", _SIDE_W, y_bf, _CENTER_W, _BOTTOM_H - CARD_H - 24),
"hand": ZoneRect("hand", 0, y_hand, _BOARD_W - CARD_W * 2 - 8, CARD_H + 8),
"graveyard": ZoneRect("graveyard", _BOARD_W - CARD_W * 2 - 4, y_bf, CARD_W + 4, CARD_H + 4),
"exile": ZoneRect("exile", _BOARD_W - CARD_W - 2, y_bf, CARD_W + 2, CARD_H + 4),
"command": ZoneRect("command", _BOARD_W - CARD_W - 2, y_hand, CARD_W + 2, CARD_H + 4),
}
def _zones_for_top(self) -> dict[str, ZoneRect]:
return {
"battlefield": ZoneRect("battlefield", _SIDE_W, PHASE_BAR_H + 8, _CENTER_W, _TOP_H - CARD_H - 16),
"hand": ZoneRect("hand", _SIDE_W, PHASE_BAR_H + _TOP_H - CARD_H - 8, _CENTER_W, CARD_H + 8),
"graveyard": ZoneRect("graveyard", _BOARD_W - CARD_W - 2, PHASE_BAR_H + 4, CARD_W + 2, CARD_H + 4),
"exile": ZoneRect("exile", _BOARD_W - CARD_W * 2 - 4, PHASE_BAR_H + 4, CARD_W + 4, CARD_H + 4),
"command": ZoneRect("command", 4, PHASE_BAR_H + 4, CARD_W + 2, CARD_H + 4),
}
def _zones_for_left(self) -> dict[str, ZoneRect]:
mid_y = _TOP_H + PHASE_BAR_H
return {
"battlefield": ZoneRect("battlefield", 4, mid_y, _SIDE_W - 4, _MID_H),
"hand": ZoneRect("hand", 4, mid_y + _MID_H - CARD_H - 8, _SIDE_W - 4, CARD_H + 8),
"graveyard": ZoneRect("graveyard", 4, mid_y + 4, CARD_W + 2, CARD_H + 4),
"exile": ZoneRect("exile", CARD_W + 8, mid_y + 4, CARD_W + 2, CARD_H + 4),
"command": ZoneRect("command", 4, mid_y + CARD_H + 12, CARD_W + 2, CARD_H + 4),
}
def _zones_for_right(self) -> dict[str, ZoneRect]:
mid_y = _TOP_H + PHASE_BAR_H
rx = _BOARD_W - _SIDE_W + 4
return {
"battlefield": ZoneRect("battlefield", rx, mid_y, _SIDE_W - 8, _MID_H),
"hand": ZoneRect("hand", rx, mid_y + _MID_H - CARD_H - 8, _SIDE_W - 8, CARD_H + 8),
"graveyard": ZoneRect("graveyard", rx, mid_y + 4, CARD_W + 2, CARD_H + 4),
"exile": ZoneRect("exile", rx + CARD_W + 4, mid_y + 4, CARD_W + 2, CARD_H + 4),
"command": ZoneRect("command", rx, mid_y + CARD_H + 12, CARD_W + 2, CARD_H + 4),
}
# ------------------------------------------------------------------
# Rendering helpers
# ------------------------------------------------------------------
def _render_player_zones(
self,
surface,
seat: int,
player,
active_seat: int,
thinking_seats: set[int],
highlighted_cards: set,
) -> None:
import pygame
seat_col = SEAT_COLOURS[seat % len(SEAT_COLOURS)]
is_active = (seat == active_seat)
is_elim = getattr(player, "eliminated", False)
is_think = seat in thinking_seats
zones = self._zones.get(seat, {})
for zone_name, zr in zones.items():
bg_col = COLOUR_ZONE_BG
border_col = COLOUR_ELIMINATED if is_elim else (
COLOUR_THINKING if is_think else (
seat_col if is_active else COLOUR_ZONE_BORDER
)
)
border_w = 2 if is_active or is_think else 1
rect = pygame.Rect(zr.x, zr.y, zr.w, zr.h)
pygame.draw.rect(surface, bg_col, rect, border_radius=4)
pygame.draw.rect(surface, border_col, rect, width=border_w, border_radius=4)
# Zone label
# (font rendering omitted here — HUD handles text labels)
# Draw cards
cards = self._get_zone_cards(player, zone_name)
face_up = (seat == self.human_seat or zone_name in ("battlefield", "graveyard", "command"))
for idx, card in enumerate(cards):
cx, cy = self._card_position(zr, idx, len(cards))
is_hl = (seat, zone_name, idx) in highlighted_cards
if face_up:
self.cr.draw_card(surface, card, cx, cy, highlighted=is_hl)
else:
self.cr.draw_card_back(surface, cx, cy, label=str(len(cards)))
break # only draw one face-down back per hidden hand
# Player name tag
# (rendered by HUD.render_player_tags)
def _render_stack_zone(self, surface, game_state) -> None:
"""Render the stack in the center of the board."""
import pygame
from commander_ai_lab.gui.constants import (
ZONE_STACK_X, ZONE_STACK_Y, ZONE_STACK_W,
)
stack = getattr(game_state, "stack", [])
if not stack:
return
rect = pygame.Rect(ZONE_STACK_X, ZONE_STACK_Y, ZONE_STACK_W, min(len(stack) * 20 + 16, 180))
pygame.draw.rect(surface, COLOUR_ZONE_BG, rect, border_radius=4)
pygame.draw.rect(surface, COLOUR_HIGHLIGHT, rect, width=2, border_radius=4)
def _render_center_dividers(self, surface) -> None:
"""Draw subtle lines separating the four quadrants."""
import pygame
col = COLOUR_ZONE_BORDER
# Horizontal dividers
y_top_end = _TOP_H + PHASE_BAR_H
y_bot_start = SCREEN_H - _BOTTOM_H - PHASE_BAR_H
pygame.draw.line(surface, col, (0, y_top_end), (_BOARD_W, y_top_end), 1)
pygame.draw.line(surface, col, (0, y_bot_start), (_BOARD_W, y_bot_start), 1)
# Vertical dividers
pygame.draw.line(surface, col, (_SIDE_W, y_top_end), (_SIDE_W, y_bot_start), 1)
pygame.draw.line(surface, col, (_BOARD_W - _SIDE_W, y_top_end), (_BOARD_W - _SIDE_W, y_bot_start), 1)
# ------------------------------------------------------------------
# Card positioning within a zone
# ------------------------------------------------------------------
@staticmethod
def _card_position(zone_rect: ZoneRect, idx: int, total: int) -> tuple[int, int]:
"""Compute pixel position for card at index idx in a zone."""
if total == 0:
return zone_rect.x + 4, zone_rect.y + 4
spacing = min(CARD_W + 6, max(20, (zone_rect.w - 8) // max(total, 1)))
x = zone_rect.x + 4 + idx * spacing
y = zone_rect.y + 4
return x, y
@staticmethod
def _card_index_at(px: int, py: int, zone_rect: ZoneRect, cards: list) -> Optional[int]:
"""Return the card index at pixel position, or None."""
if not cards:
return None
total = len(cards)
spacing = min(CARD_W + 6, max(20, (zone_rect.w - 8) // max(total, 1)))
for idx in reversed(range(total)): # top-most card first
cx = zone_rect.x + 4 + idx * spacing
cy = zone_rect.y + 4
if cx <= px < cx + CARD_W and cy <= py < cy + CARD_H:
return idx
return None
@staticmethod
def _get_zone_cards(player, zone_name: str) -> list:
mapping = {
"hand": "hand",
"battlefield": "battlefield",
"graveyard": "graveyard",
"exile": "exile",
"command": "command_zone",
}
attr = mapping.get(zone_name, zone_name)
return getattr(player, attr, []) or []
-276
View File
@@ -1,276 +0,0 @@
"""
Commander AI Lab — Card Renderer (Phase 4)
==========================================
Draws individual cards onto a Pygame surface.
Features:
- Tapped / untapped state (rotated 90 degrees)
- Name, P/T or loyalty, mana cost text
- Highlight ring (legal target indicator)
- Scryfall art placeholder (grey box + card name until image loads)
- Async art loading via a shared image cache (non-blocking)
- Right-click context menu data (returned as list of option strings,
rendered by the caller)
"""
from __future__ import annotations
import threading
from typing import Optional
try:
import pygame
_PYGAME_AVAILABLE = True
except ImportError:
_PYGAME_AVAILABLE = False
from commander_ai_lab.gui.constants import (
CARD_W, CARD_H, CARD_RADIUS, CARD_TAPPED_ANGLE,
COLOUR_CARD_BACK, COLOUR_CARD_FACE, COLOUR_CARD_TAPPED,
COLOUR_HIGHLIGHT, COLOUR_TARGET_RING, COLOUR_TEXT, COLOUR_TEXT_DIM,
COLOUR_ZONE_BORDER,
)
# ---------------------------------------------------------------------------
# Scryfall image cache (shared across all CardRenderer instances)
# ---------------------------------------------------------------------------
class _ScryfallCache:
"""
Lazy per-name image cache.
Fetches art from Scryfall API in a background thread when a card is
first drawn. Surfaces are stored as pygame.Surface objects once loaded.
Until loaded, None is stored (caller renders placeholder).
"""
def __init__(self) -> None:
self._cache: dict[str, Optional[object]] = {} # name -> Surface | None
self._pending: set[str] = set()
self._lock = threading.Lock()
def get(self, card_name: str) -> Optional[object]:
"""Return cached Surface or None (triggers background fetch if first call)."""
with self._lock:
if card_name in self._cache:
return self._cache[card_name]
if card_name not in self._pending:
self._pending.add(card_name)
t = threading.Thread(
target=self._fetch,
args=(card_name,),
daemon=True,
)
t.start()
return None
def _fetch(self, card_name: str) -> None:
"""Background thread: fetch art from Scryfall and decode into Surface."""
try:
from urllib.request import urlopen
from urllib.parse import quote
import json
import io
# Step 1: resolve card by name
name_enc = quote(card_name)
api_url = f"https://api.scryfall.com/cards/named?fuzzy={name_enc}"
with urlopen(api_url, timeout=5) as r:
data = json.loads(r.read())
# Step 2: get small image URI
image_uris = data.get("image_uris") or (
data.get("card_faces", [{}])[0].get("image_uris", {})
)
img_url = image_uris.get("small") or image_uris.get("normal")
if not img_url:
raise ValueError("No image URI found")
# Step 3: download image bytes
with urlopen(img_url, timeout=10) as r:
img_bytes = r.read()
# Step 4: decode into pygame Surface
if _PYGAME_AVAILABLE:
import pygame
buf = io.BytesIO(img_bytes)
surf = pygame.image.load(buf, "card.jpg")
surf = pygame.transform.scale(surf, (CARD_W, CARD_H))
else:
surf = None
with self._lock:
self._cache[card_name] = surf
self._pending.discard(card_name)
except Exception:
# On any failure, store None so we fall back to placeholder
with self._lock:
self._cache[card_name] = None
self._pending.discard(card_name)
# Module-level shared cache
_ART_CACHE = _ScryfallCache()
# ---------------------------------------------------------------------------
# CardRenderer
# ---------------------------------------------------------------------------
class CardRenderer:
"""
Stateless card drawing helper.
All draw methods accept a target pygame.Surface and a card dict.
Card dict schema (same as game_state.CommanderPlayer.battlefield):
{
"name": str,
"type": str, # creature / instant / land / …
"pt": str | None, # "3/3"
"tapped": bool,
"cmc": int,
"oracle": str | None,
"is_commander": bool,
}
"""
def __init__(self, font_small=None, font_med=None) -> None:
self._font_small = font_small
self._font_med = font_med
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def draw_card(
self,
surface,
card: dict,
x: int,
y: int,
highlighted: bool = False,
show_art: bool = True,
) -> None:
"""
Draw a single card at (x, y) on surface.
Tapped cards are rotated 90 degrees clockwise.
"""
if not _PYGAME_AVAILABLE:
return
import pygame
tapped = card.get("tapped", False)
# Build card surface at standard orientation
card_surf = self._build_card_surface(card, highlighted, show_art)
if tapped:
card_surf = pygame.transform.rotate(card_surf, -CARD_TAPPED_ANGLE)
surface.blit(card_surf, (x, y))
def draw_card_back(
self,
surface,
x: int,
y: int,
label: str = "",
) -> None:
"""Draw a face-down card back (used for hidden hands)."""
if not _PYGAME_AVAILABLE:
return
import pygame
rect = pygame.Rect(x, y, CARD_W, CARD_H)
pygame.draw.rect(surface, COLOUR_CARD_BACK, rect, border_radius=CARD_RADIUS)
pygame.draw.rect(surface, COLOUR_ZONE_BORDER, rect, width=2, border_radius=CARD_RADIUS)
if label and self._font_small:
lbl = self._font_small.render(label[:12], True, COLOUR_TEXT_DIM)
surface.blit(lbl, (x + 4, y + CARD_H - 18))
def get_context_menu_options(self, card: dict) -> list[str]:
"""
Return right-click context menu options for a card.
Options depend on card type and zone.
"""
options = []
if card.get("type", "").lower() == "land":
options.append("Tap for mana")
if card.get("is_creature") or "creature" in card.get("type", "").lower():
options.append("Declare attacker")
if card.get("oracle"):
options.append("View oracle text")
options.append("Move to graveyard")
options.append("Move to exile")
return options
def card_rect(
self,
x: int,
y: int,
tapped: bool = False,
) -> tuple:
"""Return the bounding (x, y, w, h) tuple for hit-testing."""
if tapped:
return (x, y, CARD_H, CARD_W) # rotated dimensions
return (x, y, CARD_W, CARD_H)
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _build_card_surface(self, card: dict, highlighted: bool, show_art: bool):
"""Construct a CARD_W x CARD_H surface for the card."""
import pygame
surf = pygame.Surface((CARD_W, CARD_H), pygame.SRCALPHA)
rect = pygame.Rect(0, 0, CARD_W, CARD_H)
# Background
face_col = COLOUR_CARD_TAPPED if card.get("tapped") else COLOUR_CARD_FACE
pygame.draw.rect(surf, face_col, rect, border_radius=CARD_RADIUS)
# Scryfall art (or placeholder)
if show_art:
art = _ART_CACHE.get(card.get("name", ""))
if art:
surf.blit(art, (0, 0))
else:
# Art placeholder: grey block in upper portion
art_rect = pygame.Rect(4, 4, CARD_W - 8, 54)
pygame.draw.rect(surf, (80, 90, 110), art_rect, border_radius=3)
# Border
border_col = COLOUR_HIGHLIGHT if highlighted else COLOUR_ZONE_BORDER
border_w = 3 if highlighted else 1
pygame.draw.rect(surf, border_col, rect, width=border_w, border_radius=CARD_RADIUS)
# Commander indicator (gold star)
if card.get("is_commander"):
pygame.draw.circle(surf, (255, 210, 0), (CARD_W - 10, 10), 6)
# Text overlay
if self._font_small:
name = card.get("name", "Unknown")[:14]
name_surf = self._font_small.render(name, True, (10, 10, 10))
surf.blit(name_surf, (4, CARD_H - 38))
pt = card.get("pt")
if pt and self._font_small:
pt_surf = self._font_small.render(pt, True, (10, 10, 10))
surf.blit(pt_surf, (CARD_W - 28, CARD_H - 18))
cmc = card.get("cmc")
if cmc is not None and self._font_small:
cmc_str = str(int(cmc)) if isinstance(cmc, float) else str(cmc)
cmc_surf = self._font_small.render(cmc_str, True, (10, 10, 10))
surf.blit(cmc_surf, (4, CARD_H - 18))
# Target ring
if highlighted:
pygame.draw.rect(
surf, COLOUR_TARGET_RING, rect,
width=3, border_radius=CARD_RADIUS,
)
return surf
-93
View File
@@ -1,93 +0,0 @@
"""
Commander AI Lab — GUI Constants (Phase 4)
==========================================
All magic numbers in one place. Adjust here to re-skin the entire UI.
"""
# --- Window ---
SCREEN_W: int = 1280
SCREEN_H: int = 960
FPS: int = 60
TITLE: str = "Commander AI Lab"
# --- Card dimensions ---
CARD_W: int = 90
CARD_H: int = 126
CARD_RADIUS: int = 6 # rounded corner radius
CARD_TAPPED_ANGLE: float = 90.0 # degrees clockwise
# --- Zone layout (4-player split: top / left / right / bottom) ---
# Each player occupies one quadrant. Zones inside each quadrant:
ZONE_HAND_H: int = CARD_H + 16
ZONE_BF_H: int = 220 # battlefield height per player
ZONE_GRAVEYARD_W: int = CARD_W + 12
ZONE_EXILE_W: int = CARD_W + 12
ZONE_COMMAND_W: int = CARD_W + 12
ZONE_STACK_W: int = 160
ZONE_STACK_X: int = (SCREEN_W - ZONE_STACK_W) // 2
ZONE_STACK_Y: int = (SCREEN_H - 180) // 2
# --- Narration panel (right side) ---
NARRATION_X: int = SCREEN_W - 270
NARRATION_Y: int = 40
NARRATION_W: int = 260
NARRATION_H: int = SCREEN_H - 80
NARRATION_MAX_LINES: int = 18
# --- Phase / step indicator ---
PHASE_BAR_H: int = 32
# --- Life total / commander damage matrix ---
LIFE_PANEL_W: int = 120
LIFE_PANEL_H: int = 100
# --- Colours (R, G, B) ---
COLOUR_BG = (18, 20, 30)
COLOUR_ZONE_BG = (28, 32, 48)
COLOUR_ZONE_BORDER = (60, 70, 100)
COLOUR_CARD_BACK = (40, 55, 90)
COLOUR_CARD_FACE = (230, 220, 195)
COLOUR_CARD_TAPPED = (150, 130, 80)
COLOUR_HIGHLIGHT = (255, 220, 50)
COLOUR_TARGET_RING = (255, 80, 80)
COLOUR_TEXT = (240, 240, 240)
COLOUR_TEXT_DIM = (140, 140, 160)
COLOUR_PHASE_BAR = (35, 40, 60)
COLOUR_PHASE_ACTIVE= (60, 130, 220)
COLOUR_NARRATION_BG= (22, 26, 40)
COLOUR_LIFE_HIGH = (80, 200, 100)
COLOUR_LIFE_MED = (220, 180, 50)
COLOUR_LIFE_LOW = (220, 60, 60)
COLOUR_ELIMINATED = (80, 80, 80)
COLOUR_THINKING = (100, 180, 255)
# Player seat colours (used to tint zone borders and name labels)
SEAT_COLOURS = [
(70, 140, 255), # seat 0 — blue
(255, 100, 80), # seat 1 — red
(80, 210, 120), # seat 2 — green
(220, 170, 50), # seat 3 — gold
]
# --- Commander phases list (for phase bar) ---
COMMANDER_PHASES = [
"untap", "upkeep", "draw",
"main1", "begin_combat", "declare_attackers",
"declare_blockers", "combat_damage", "end_combat",
"main2", "end_step", "cleanup",
]
PHASE_LABELS = {
"untap": "Untap",
"upkeep": "Upkeep",
"draw": "Draw",
"main1": "Main 1",
"begin_combat": "Begin Combat",
"declare_attackers": "Attackers",
"declare_blockers": "Blockers",
"combat_damage": "Damage",
"end_combat": "End Combat",
"main2": "Main 2",
"end_step": "End Step",
"cleanup": "Cleanup",
}
-375
View File
@@ -1,375 +0,0 @@
"""
Commander AI Lab — GameWindow (Phase 4)
========================================
Top-level Pygame application class.
Responsibilities:
- Initialise Pygame display, fonts, clock
- Own the main game loop (run())
- Bridge between CommanderTurnManager events and GUI components
- Handle human player input (click-to-play, right-click context menu)
- Show end-of-game summary screen with win condition + final stats
Usage (blocking, runs until window closes)::
from commander_ai_lab.gui.game_window import GameWindow
from commander_ai_lab.sim.ai_opponent import create_four_player_ai_roster
from commander_ai_lab.sim.game_state import CommanderGameState
gs = CommanderGameState(...) # build your game state
ai_roster = create_four_player_ai_roster()
window = GameWindow(game_state=gs, ai_opponents=ai_roster, human_seat=0)
window.run() # blocks until game over or window closed
"""
from __future__ import annotations
import asyncio
import threading
from typing import Optional
try:
import pygame
_PYGAME_AVAILABLE = True
except ImportError:
_PYGAME_AVAILABLE = False
from commander_ai_lab.gui.constants import (
SCREEN_W, SCREEN_H, FPS, TITLE,
COLOUR_BG, COLOUR_TEXT, COLOUR_TEXT_DIM, COLOUR_HIGHLIGHT,
SEAT_COLOURS, NARRATION_X, NARRATION_W,
)
from commander_ai_lab.gui.board import BoardRenderer
from commander_ai_lab.gui.card_renderer import CardRenderer
from commander_ai_lab.gui.hud import HUD
from commander_ai_lab.gui.narration_panel import NarrationPanel
from commander_ai_lab.sim.turn_manager import CommanderTurnManager, GameEvent, TurnManagerConfig
class GameWindow:
"""
Main Pygame application window for Commander AI Lab.
Parameters
----------
game_state : CommanderGameState — mutable game state.
ai_opponents : List of AIOpponent instances (Phase 2).
human_seat : Seat index for the human player (default 0).
Pass None for a fully AI game.
config : TurnManagerConfig for the turn manager.
"""
def __init__(
self,
game_state,
ai_opponents: list,
human_seat: Optional[int] = 0,
config: Optional[TurnManagerConfig] = None,
) -> None:
self.gs = game_state
self.human_seat = human_seat
self._ai_opponents = ai_opponents
self._config = config or TurnManagerConfig(ai_decision_delay=1.0)
# Turn manager
human_seats = {human_seat} if human_seat is not None else set()
self._tm = CommanderTurnManager(
game_state=game_state,
ai_opponents=ai_opponents,
human_seats=human_seats,
on_event=self._on_game_event,
on_thinking=self._on_thinking,
config=self._config,
)
# GUI state
self._thinking_seats: set[int] = set()
self._highlighted_cards: set = set()
self._selected_card = None
self._context_menu = None # (x, y, options)
self._game_over_info: Optional[dict] = None
# Pygame objects (initialised in run())
self._screen = None
self._clock = None
self._board = None
self._hud = None
self._narration = None
self._cr = None
# Commander damage matrix: {(dealer, receiver): int}
self._cmd_damage: dict[tuple, int] = {}
# ------------------------------------------------------------------
# Public entry point
# ------------------------------------------------------------------
def run(self) -> None:
"""Initialise Pygame and run the main loop (blocking)."""
if not _PYGAME_AVAILABLE:
raise RuntimeError(
"Pygame is not installed. Run: pip install pygame"
)
pygame.init()
pygame.display.set_caption(TITLE)
self._screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
self._clock = pygame.time.Clock()
# Fonts
font_small = pygame.font.SysFont("segoeui", 11)
font_med = pygame.font.SysFont("segoeui", 13)
font_large = pygame.font.SysFont("segoeui", 28, bold=True)
# GUI components
self._cr = CardRenderer(font_small=font_small, font_med=font_med)
self._board = BoardRenderer(card_renderer=self._cr, human_seat=self.human_seat or 0)
self._hud = HUD(font_small=font_small, font_med=font_med, font_large=font_large)
self._narration = NarrationPanel(font_small=font_small)
# Start the async turn manager in a background thread
self._loop = asyncio.new_event_loop()
self._game_thread = threading.Thread(
target=self._run_game_loop_async,
daemon=True,
)
self._game_thread.start()
# Main render loop
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN:
self._handle_mouse_click(event)
elif event.type == pygame.MOUSEWHEEL:
if event.y > 0:
self._narration.scroll_up()
else:
self._narration.scroll_down()
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
self._render_frame()
self._clock.tick(FPS)
pygame.quit()
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def _render_frame(self) -> None:
if self._game_over_info:
self._render_game_over_screen()
return
# Board
self._board.render(
self._screen,
self.gs,
active_seat=self.gs.active_player_seat,
thinking_seats=self._thinking_seats,
highlighted_cards=self._highlighted_cards,
)
# HUD
self._hud.render_phase_bar(
self._screen,
current_phase=self.gs.current_phase,
active_seat=self.gs.active_player_seat,
player_name=self.gs.players[self.gs.active_player_seat].name,
)
self._hud.render_life_totals(self._screen, self.gs.players)
self._hud.render_commander_damage_matrix(
self._screen, self.gs.players, self._cmd_damage,
)
self._hud.render_player_tags(
self._screen, self.gs.players,
self.gs.active_player_seat, self._thinking_seats,
)
# Narration panel
self._narration.render(self._screen, turn=self.gs.turn)
# Context menu (right-click)
if self._context_menu:
self._render_context_menu()
pygame.display.flip()
def _render_game_over_screen(self) -> None:
"""Full-screen game over summary."""
import pygame
self._screen.fill(COLOUR_BG)
info = self._game_over_info or {}
winner_seat = info.get("winner", 0)
reason = info.get("reason", "")
font_title = pygame.font.SysFont("segoeui", 48, bold=True)
font_body = pygame.font.SysFont("segoeui", 20)
font_small = pygame.font.SysFont("segoeui", 14)
winner_name = self.gs.players[winner_seat].name if winner_seat is not None else "Draw"
col = SEAT_COLOURS[winner_seat % len(SEAT_COLOURS)] if winner_seat is not None else COLOUR_TEXT
title = font_title.render(f"{winner_name} wins!", True, col)
self._screen.blit(title, (SCREEN_W // 2 - title.get_width() // 2, 140))
sub = font_body.render(f"by {reason}", True, COLOUR_TEXT_DIM)
self._screen.blit(sub, (SCREEN_W // 2 - sub.get_width() // 2, 200))
# Final stats per player
y = 270
for seat, player in enumerate(self.gs.players):
name = player.name
life = player.life
elim = player.eliminated
stat_col = COLOUR_TEXT_DIM if elim else SEAT_COLOURS[seat % len(SEAT_COLOURS)]
row = font_body.render(
f"{'[ELIM] ' if elim else ' '}P{seat} {name:<16} Life: {life:>3}",
True, stat_col,
)
self._screen.blit(row, (SCREEN_W // 2 - 200, y))
y += 30
hint = font_small.render("Press ESC to exit", True, COLOUR_TEXT_DIM)
self._screen.blit(hint, (SCREEN_W // 2 - hint.get_width() // 2, y + 30))
pygame.display.flip()
def _render_context_menu(
self,
) -> None:
"""Draw right-click context menu."""
import pygame
if not self._context_menu:
return
x, y, options = self._context_menu
font = pygame.font.SysFont("segoeui", 13)
item_h = 22
w = 180
bg = pygame.Rect(x, y, w, item_h * len(options) + 8)
pygame.draw.rect(self._screen, (30, 35, 50), bg, border_radius=4)
pygame.draw.rect(self._screen, COLOUR_HIGHLIGHT, bg, width=1, border_radius=4)
for i, opt in enumerate(options):
txt = font.render(opt, True, COLOUR_TEXT)
self._screen.blit(txt, (x + 6, y + 4 + i * item_h))
# ------------------------------------------------------------------
# Input handling
# ------------------------------------------------------------------
def _handle_mouse_click(self, event) -> None:
import pygame
px, py = event.pos
# Dismiss context menu on any click
if self._context_menu:
self._context_menu = None
return
hit = self._board.hit_test(px, py, self.gs)
if hit is None:
return
if event.button == 3: # right-click
options = self._cr.get_context_menu_options(hit.card)
self._context_menu = (px, py, options)
return
# Left-click: human play flow
if self.human_seat is not None and hit.seat == self.human_seat:
if hit.zone == "hand":
# Select card → highlight legal targets
if self._selected_card and self._selected_card.card_index == hit.card_index:
# Confirm: inject move into turn manager
legal = self._tm.get_legal_moves(self.human_seat)
move_id = self._find_move_for_card(hit.card, legal)
if move_id is not None:
asyncio.run_coroutine_threadsafe(
self._tm.human_play(move_id), self._loop
)
self._selected_card = None
self._highlighted_cards.clear()
else:
# First click: select + highlight
self._selected_card = hit
self._board.set_selected(hit)
self._highlighted_cards = self._compute_legal_targets(hit)
@staticmethod
def _find_move_for_card(card: dict, legal_moves: list) -> Optional[int]:
"""Match a hand card to a legal move by name."""
name = card.get("name", "").lower()
for m in legal_moves:
if name in m.get("description", "").lower():
return m["id"]
return legal_moves[0]["id"] if legal_moves else None
def _compute_legal_targets(
self,
selected: object,
) -> set:
"""Return set of (seat, zone, idx) tuples for legal targets."""
# Simplified: highlight all opponent battlefield cards as targets
targets = set()
if self.human_seat is None:
return targets
for seat, player in enumerate(self.gs.players):
if seat == self.human_seat:
continue
for idx in range(len(getattr(player, "battlefield", []))):
targets.add((seat, "battlefield", idx))
return targets
# ------------------------------------------------------------------
# Async game loop (runs in background thread)
# ------------------------------------------------------------------
def _run_game_loop_async(self) -> None:
"""Run the turn manager coroutine in a dedicated event loop thread."""
asyncio.set_event_loop(self._loop)
self._loop.run_until_complete(self._tm.run_game())
# ------------------------------------------------------------------
# Turn manager callbacks
# ------------------------------------------------------------------
async def _on_game_event(self, event: GameEvent) -> None:
"""Handle game events from the turn manager."""
if event.event_type == "game_over":
self._game_over_info = event.extra
elif event.event_type == "action" and event.narration:
self._narration.add(
seat=event.seat,
text=f"{event.player_name}: {event.narration}",
turn=event.turn,
)
elif event.event_type == "phase_change":
self._narration.add(
seat=-1,
text=f"T{event.turn} {event.player_name} — {event.phase}",
turn=event.turn,
)
elif event.event_type == "elimination":
self._narration.add(
seat=event.seat,
text=f"{event.player_name} has been eliminated!",
turn=event.turn,
)
async def _on_thinking(self, seat: int, is_thinking: bool) -> None:
"""Show / hide the AI thinking indicator."""
if is_thinking:
self._thinking_seats.add(seat)
self._narration.add_thinking(seat)
else:
self._thinking_seats.discard(seat)
self._narration.clear_thinking(seat)
-210
View File
@@ -1,210 +0,0 @@
"""
Commander AI Lab — HUD (Phase 4)
=================================
Renders the phase/step indicator bar, life total trackers,
4-player commander damage matrix, and player name tags.
"""
from __future__ import annotations
try:
import pygame
_PYGAME_AVAILABLE = True
except ImportError:
_PYGAME_AVAILABLE = False
from commander_ai_lab.gui.constants import (
SCREEN_W, SCREEN_H, PHASE_BAR_H,
COMMANDER_PHASES, PHASE_LABELS,
COLOUR_PHASE_BAR, COLOUR_PHASE_ACTIVE, COLOUR_TEXT, COLOUR_TEXT_DIM,
COLOUR_LIFE_HIGH, COLOUR_LIFE_MED, COLOUR_LIFE_LOW,
COLOUR_ELIMINATED, COLOUR_THINKING, COLOUR_BG,
SEAT_COLOURS, NARRATION_X,
LIFE_PANEL_W, LIFE_PANEL_H,
)
_BOARD_W = NARRATION_X - 4
class HUD:
"""
Draws all HUD elements onto the display surface.
Parameters
----------
font_small, font_med, font_large : pygame.font.Font | None
Pass pre-loaded fonts; None falls back to system default.
"""
def __init__(self, font_small=None, font_med=None, font_large=None) -> None:
self._fs = font_small
self._fm = font_med
self._fl = font_large
# ------------------------------------------------------------------
# Public render methods
# ------------------------------------------------------------------
def render_phase_bar(
self,
surface,
current_phase: str,
active_seat: int,
player_name: str,
) -> None:
"""
Draw the phase / step indicator bar at the very top of the screen.
Highlights the current phase, shows active player name on the left.
"""
if not _PYGAME_AVAILABLE:
return
import pygame
bar_rect = pygame.Rect(0, 0, _BOARD_W, PHASE_BAR_H)
pygame.draw.rect(surface, COLOUR_PHASE_BAR, bar_rect)
# Active player label
seat_col = SEAT_COLOURS[active_seat % len(SEAT_COLOURS)]
if self._fs:
lbl = self._fs.render(f"{player_name}", True, seat_col)
surface.blit(lbl, (6, (PHASE_BAR_H - lbl.get_height()) // 2))
# Phase tabs
total_phases = len(COMMANDER_PHASES)
tab_w = (_BOARD_W - 90) // total_phases
for i, phase in enumerate(COMMANDER_PHASES):
tx = 90 + i * tab_w
trect = pygame.Rect(tx, 0, tab_w - 2, PHASE_BAR_H)
is_active_phase = (phase == current_phase)
bg = COLOUR_PHASE_ACTIVE if is_active_phase else COLOUR_PHASE_BAR
pygame.draw.rect(surface, bg, trect, border_radius=3)
if self._fs:
label = PHASE_LABELS.get(phase, phase)
col = COLOUR_TEXT if is_active_phase else COLOUR_TEXT_DIM
txt = self._fs.render(label[:8], True, col)
surface.blit(txt, (tx + 3, (PHASE_BAR_H - txt.get_height()) // 2))
def render_life_totals(
self,
surface,
players: list,
) -> None:
"""
Draw life total boxes in the four corners of the board.
Corner positions match seat quadrant positions.
"""
if not _PYGAME_AVAILABLE:
return
import pygame
# Corner positions: (x, y) per seat
corners = {
0: (_BOARD_W - LIFE_PANEL_W - 4, SCREEN_H - LIFE_PANEL_H - 4), # bottom-right
1: (_BOARD_W - LIFE_PANEL_W - 4, SCREEN_H // 2 - LIFE_PANEL_H // 2), # right-mid
2: (_BOARD_W - LIFE_PANEL_W - 4, PHASE_BAR_H + 4), # top-right
3: (4, SCREEN_H // 2 - LIFE_PANEL_H // 2), # left-mid
}
for seat, player in enumerate(players):
if seat not in corners:
continue
cx, cy = corners[seat]
life = getattr(player, "life", 40)
elim = getattr(player, "eliminated", False)
name = getattr(player, "name", f"P{seat}")
# Life colour
if elim:
life_col = COLOUR_ELIMINATED
elif life >= 30:
life_col = COLOUR_LIFE_HIGH
elif life >= 15:
life_col = COLOUR_LIFE_MED
else:
life_col = COLOUR_LIFE_LOW
panel = pygame.Rect(cx, cy, LIFE_PANEL_W, LIFE_PANEL_H)
pygame.draw.rect(surface, (22, 26, 40), panel, border_radius=6)
pygame.draw.rect(surface, SEAT_COLOURS[seat % len(SEAT_COLOURS)], panel, width=2, border_radius=6)
if self._fl:
life_txt = self._fl.render(str(life), True, life_col)
surface.blit(life_txt, (cx + 10, cy + 10))
if self._fs:
name_txt = self._fs.render(name[:12], True, COLOUR_TEXT_DIM)
surface.blit(name_txt, (cx + 6, cy + LIFE_PANEL_H - 20))
def render_commander_damage_matrix(
self,
surface,
players: list,
commander_damage: dict, # {(dealer_seat, receiver_seat): int}
) -> None:
"""
Draw the 4x4 commander damage grid.
Rows = damage dealer, columns = damage receiver.
Positioned in the center-bottom of the board.
"""
if not _PYGAME_AVAILABLE or not self._fs:
return
import pygame
n = len(players)
cell = 28
ox = (_BOARD_W - n * cell - 30) // 2
oy = SCREEN_H - PHASE_BAR_H - n * cell - 40
# Header labels
for i, player in enumerate(players):
col = SEAT_COLOURS[i % len(SEAT_COLOURS)]
lbl = self._fs.render(f"P{i}", True, col)
surface.blit(lbl, (ox + 32 + i * cell, oy - 16))
surface.blit(lbl, (ox + 4, oy + i * cell))
# Cells
for dealer in range(n):
for recv in range(n):
if dealer == recv:
continue
dmg = commander_damage.get((dealer, recv), 0)
rect = pygame.Rect(ox + 32 + recv * cell, oy + dealer * cell, cell - 2, cell - 2)
bg = (60, 20, 20) if dmg >= 21 else (28, 32, 48)
pygame.draw.rect(surface, bg, rect, border_radius=3)
pygame.draw.rect(surface, COLOUR_ZONE_BORDER, rect, width=1, border_radius=3)
if dmg > 0:
col = (255, 80, 80) if dmg >= 21 else COLOUR_TEXT_DIM
txt = self._fs.render(str(dmg), True, col)
surface.blit(txt, (rect.x + 6, rect.y + 6))
def render_player_tags(
self,
surface,
players: list,
active_seat: int,
thinking_seats: set[int],
) -> None:
"""Render player name tags near each quadrant."""
if not _PYGAME_AVAILABLE or not self._fm:
return
import pygame
tag_positions = {
0: (4, SCREEN_H - PHASE_BAR_H - 20),
1: (_BOARD_W - 130, SCREEN_H // 2),
2: (4, PHASE_BAR_H + 4),
3: (4, SCREEN_H // 2),
}
for seat, player in enumerate(players):
if seat not in tag_positions:
continue
tx, ty = tag_positions[seat]
name = getattr(player, "name", f"P{seat}")
elim = getattr(player, "eliminated", False)
col = COLOUR_ELIMINATED if elim else (
COLOUR_THINKING if seat in thinking_seats else
SEAT_COLOURS[seat % len(SEAT_COLOURS)]
)
think_sfx = " [thinking...]" if seat in thinking_seats else ""
tag = self._fm.render(f"{name}{think_sfx}", True, col)
surface.blit(tag, (tx, ty))
-194
View File
@@ -1,194 +0,0 @@
"""
Commander AI Lab — Narration Panel (Phase 4)
=============================================
Scrolling log panel (right-side strip) that shows AI flavor text,
game events, and phase transitions in a chat-style feed.
Features:
- Colour-coded by player seat
- Auto-scrolls to latest message
- Capped at NARRATION_MAX_LINES to avoid overflow
- "AI is thinking..." animated indicator for active inference seats
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
try:
import pygame
_PYGAME_AVAILABLE = True
except ImportError:
_PYGAME_AVAILABLE = False
from commander_ai_lab.gui.constants import (
SCREEN_H, NARRATION_X, NARRATION_Y, NARRATION_W, NARRATION_H,
NARRATION_MAX_LINES,
COLOUR_NARRATION_BG, COLOUR_TEXT, COLOUR_TEXT_DIM,
COLOUR_THINKING, COLOUR_ZONE_BORDER,
SEAT_COLOURS,
)
@dataclass
class NarrationEntry:
seat: int
text: str
is_thinking: bool = False
turn: int = 0
class NarrationPanel:
"""
Scrolling AI narration / event log panel.
Usage::
panel = NarrationPanel(font_small=font)
panel.add(seat=1, text="Timmy attacks with everything!")
panel.add_thinking(seat=2) # show spinner
panel.clear_thinking(seat=2) # remove spinner when done
panel.render(surface)
"""
def __init__(self, font_small=None) -> None:
self._font = font_small
self._entries: list[NarrationEntry] = []
self._thinking: dict[int, bool] = {} # seat -> thinking flag
self._scroll_offset: int = 0
self._anim_tick: int = 0
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def add(
self,
seat: int,
text: str,
turn: int = 0,
is_event: bool = False,
) -> None:
"""
Append a narration or event line.
Args:
seat: Seat index (0–3) for colour coding. Use -1 for system events.
text: Line text. Long lines are auto-wrapped.
turn: Current game turn (shown as prefix).
is_event: True for phase/system events (shown dimmer).
"""
# Word-wrap to fit panel width
for line in self._wrap(text, max_chars=32):
self._entries.append(NarrationEntry(
seat=seat,
text=line,
is_thinking=False,
turn=turn,
))
# Trim to max lines
if len(self._entries) > NARRATION_MAX_LINES * 3:
self._entries = self._entries[-(NARRATION_MAX_LINES * 3):]
# Auto-scroll to bottom
self._scroll_to_bottom()
def add_thinking(
self,
seat: int,
) -> None:
"""Show 'thinking...' animation for seat."""
self._thinking[seat] = True
def clear_thinking(
self,
seat: int,
) -> None:
"""Remove thinking indicator for seat."""
self._thinking.pop(seat, None)
def clear(self) -> None:
"""Clear all entries."""
self._entries.clear()
self._thinking.clear()
self._scroll_offset = 0
def render(
self,
surface,
turn: int = 0,
) -> None:
"""
Render the narration panel onto the display surface.
Call once per frame from the main game loop.
"""
if not _PYGAME_AVAILABLE:
return
import pygame
self._anim_tick += 1
panel_rect = pygame.Rect(NARRATION_X, NARRATION_Y, NARRATION_W, NARRATION_H)
pygame.draw.rect(surface, COLOUR_NARRATION_BG, panel_rect, border_radius=6)
pygame.draw.rect(surface, COLOUR_ZONE_BORDER, panel_rect, width=1, border_radius=6)
if not self._font:
return
line_h = self._font.get_linesize() + 2
visible = NARRATION_H // line_h
start = max(0, len(self._entries) - visible - self._scroll_offset)
shown = self._entries[start: start + visible]
y = NARRATION_Y + 6
for entry in shown:
col = SEAT_COLOURS[entry.seat % len(SEAT_COLOURS)] if entry.seat >= 0 else COLOUR_TEXT_DIM
txt = self._font.render(entry.text[:36], True, col)
surface.blit(txt, (NARRATION_X + 6, y))
y += line_h
# Thinking indicators (animated dots)
for seat, is_thinking in self._thinking.items():
if not is_thinking:
continue
dots = "." * ((self._anim_tick // 20) % 4)
p_name = f"P{seat}"
think_txt = self._font.render(
f"{p_name} thinking{dots}", True, COLOUR_THINKING
)
surface.blit(think_txt, (NARRATION_X + 6, y))
y += line_h
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _scroll_to_bottom(self) -> None:
self._scroll_offset = 0
def scroll_up(self, lines: int = 3) -> None:
self._scroll_offset = min(
self._scroll_offset + lines,
max(0, len(self._entries) - 1),
)
def scroll_down(self, lines: int = 3) -> None:
self._scroll_offset = max(0, self._scroll_offset - lines)
@staticmethod
def _wrap(text: str, max_chars: int) -> list[str]:
"""Simple word-wrap to max_chars per line."""
words = text.split()
lines = []
current = ""
for word in words:
if len(current) + len(word) + 1 <= max_chars:
current = (current + " " + word).strip()
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
return lines or [text[:max_chars]]
+26 -30
View File
@@ -13,10 +13,6 @@ Functions:
"""
from __future__ import annotations
import logging
log = logging.getLogger("commander_ai_lab.lab.experiments")
import time
from typing import Optional
@@ -242,27 +238,27 @@ def _generate_training_deck() -> list[Card]:
def _print_stats(stats: dict) -> None:
"""Pretty-print simulation stats."""
log.info("")
log.info("=" * 60)
log.info(f" SIMULATION RESULTS: {stats['deck_name']} vs {stats['opponent_name']}")
log.info("=" * 60)
log.info(f" Games played: {stats['total_games']}")
log.info(f" Wins / Losses: {stats['wins']} / {stats['losses']}")
log.info(f" Win Rate: {stats['win_rate']}%")
log.info(f" Avg Turns: {stats['avg_turns']}")
log.info("-" * 60)
log.info(f" Avg Damage Dealt: {stats['avg_damage_dealt']}")
log.info(f" Avg Damage Received: {stats['avg_damage_received']}")
log.info(f" Avg Spells Cast: {stats['avg_spells_cast']}")
log.info(f" Avg Creatures Played: {stats['avg_creatures_played']}")
log.info(f" Avg Removal Used: {stats['avg_removal_used']}")
log.info(f" Avg Ramp Played: {stats['avg_ramp_played']}")
log.info(f" Avg Cards Drawn: {stats['avg_cards_drawn']}")
log.info(f" Avg Max Board Size: {stats['avg_max_board_size']}")
log.info("-" * 60)
log.info(f" Elapsed: {stats['elapsed_seconds']}s")
log.info("=" * 60)
log.info("")
print()
print("=" * 60)
print(f" SIMULATION RESULTS: {stats['deck_name']} vs {stats['opponent_name']}")
print("=" * 60)
print(f" Games played: {stats['total_games']}")
print(f" Wins / Losses: {stats['wins']} / {stats['losses']}")
print(f" Win Rate: {stats['win_rate']}%")
print(f" Avg Turns: {stats['avg_turns']}")
print("-" * 60)
print(f" Avg Damage Dealt: {stats['avg_damage_dealt']}")
print(f" Avg Damage Received: {stats['avg_damage_received']}")
print(f" Avg Spells Cast: {stats['avg_spells_cast']}")
print(f" Avg Creatures Played: {stats['avg_creatures_played']}")
print(f" Avg Removal Used: {stats['avg_removal_used']}")
print(f" Avg Ramp Played: {stats['avg_ramp_played']}")
print(f" Avg Cards Drawn: {stats['avg_cards_drawn']}")
print(f" Avg Max Board Size: {stats['avg_max_board_size']}")
print("-" * 60)
print(f" Elapsed: {stats['elapsed_seconds']}s")
print("=" * 60)
print()
def _cli_main() -> None:
@@ -298,15 +294,15 @@ def _run_smoke_test() -> None:
"Harmonize", "Read the Bones", "Phyrexian Arena",
]
log.info("Building test deck (Korvold Aristocrats)...")
print("Building test deck (Korvold Aristocrats)...")
test_deck = build_deck(test_decklist)
log.info(f" Deck size: {len(test_deck)} cards")
print(f" Deck size: {len(test_deck)} cards")
log.info("\nRunning single game...")
print("\nRunning single game...")
single = run_single_game(test_deck, name_a="Korvold")
log.info(f" Winner: {single['winner_name']} in {single['turns']} turns")
print(f" Winner: {single['winner_name']} in {single['turns']} turns")
log.info("\nRunning Monte Carlo simulation (10 games)...")
print("\nRunning Monte Carlo simulation (10 games)...")
stats = run_simulation(test_deck, num_games=10, name_a="Korvold")
_print_stats(stats)
@@ -1,23 +0,0 @@
"""
Commander AI Lab — Multiplayer Package (Phase 8)
Exposes:
LobbyManager — room creation, player slots, ready-check, game launch
ChatChannel — per-room in-game chat with history + moderation
SpectatorManager — read-only WebSocket feeds for spectators
LANDiscovery — UDP beacon broadcast / listener for LAN host discovery
PrivateHandBus — routes private-hand snapshots to the correct seat only
"""
from commander_ai_lab.multiplayer.lobby import LobbyManager, LobbyRoom, LobbySlot, RoomState
from commander_ai_lab.multiplayer.chat import ChatChannel, ChatMessage, ChatRole
from commander_ai_lab.multiplayer.spectator import SpectatorManager
from commander_ai_lab.multiplayer.lan import LANDiscovery, LANBeacon
from commander_ai_lab.multiplayer.hands import PrivateHandBus
__all__ = [
"LobbyManager", "LobbyRoom", "LobbySlot", "RoomState",
"ChatChannel", "ChatMessage", "ChatRole",
"SpectatorManager",
"LANDiscovery", "LANBeacon",
"PrivateHandBus",
]
-179
View File
@@ -1,179 +0,0 @@
"""
Commander AI Lab — In-Game Chat Channel (Phase 8)
==================================================
Per-room chat with:
- Role-based message tagging (PLAYER, SPECTATOR, SYSTEM, AI)
- Message history (capped at MAX_HISTORY)
- Simple profanity / command filter hooks
- Async broadcast to registered WebSocket send callbacks
- System message helpers (player joined, game events, deal offers)
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Callable, Awaitable, Optional
MAX_HISTORY = 500
MAX_MSG_LENGTH = 400
class ChatRole(Enum):
PLAYER = auto()
SPECTATOR = auto()
SYSTEM = auto()
AI = auto()
@dataclass
class ChatMessage:
sender: str
text: str
role: ChatRole = ChatRole.PLAYER
seat: Optional[int] = None
timestamp: float = field(default_factory=time.time)
room_id: str = ""
def to_dict(self) -> dict:
return {
"sender": self.sender,
"text": self.text,
"role": self.role.name,
"seat": self.seat,
"timestamp": self.timestamp,
"room_id": self.room_id,
}
# Async send callable: receives a serialised message dict
ChatSendFn = Callable[[dict], Awaitable[None]]
class ChatChannel:
"""
Chat bus for a single game room.
Usage::
chat = ChatChannel(room_id="abc123")
chat.register(seat=0, send_fn=ws_send)
await chat.send(sender="Alice", text="Let's make a deal", seat=0)
await chat.system("Game started!")
"""
def __init__(self, room_id: str) -> None:
self.room_id = room_id
self._history: list[ChatMessage] = []
# seat -> send callback (players)
self._player_handlers: dict[int, ChatSendFn] = {}
# list of callbacks for spectators + global listeners
self._spectator_handlers: list[ChatSendFn] = []
self._global_handlers: list[ChatSendFn] = []
# ------------------------------------------------------------------
# Registration
# ------------------------------------------------------------------
def register(self, seat: int, send_fn: ChatSendFn) -> None:
"""Register a player WebSocket send callback."""
self._player_handlers[seat] = send_fn
def register_spectator(self, send_fn: ChatSendFn) -> None:
self._spectator_handlers.append(send_fn)
def register_global(self, send_fn: ChatSendFn) -> None:
"""Receives every message regardless of role."""
self._global_handlers.append(send_fn)
def unregister(self, seat: int) -> None:
self._player_handlers.pop(seat, None)
# ------------------------------------------------------------------
# Sending
# ------------------------------------------------------------------
async def send(
self,
sender: str,
text: str,
seat: Optional[int] = None,
role: ChatRole = ChatRole.PLAYER,
) -> ChatMessage:
"""
Broadcast a chat message to all connected seats and spectators.
Returns the stored ChatMessage.
"""
text = text[:MAX_MSG_LENGTH]
msg = ChatMessage(
sender=sender,
text=text,
role=role,
seat=seat,
room_id=self.room_id,
)
self._store(msg)
await self._broadcast(msg)
return msg
async def system(self, text: str) -> None:
"""Broadcast a SYSTEM message (e.g. 'P2 has conceded')."""
await self.send(sender="System", text=text, role=ChatRole.SYSTEM)
async def ai_says(
self,
ai_name: str,
seat: int,
text: str,
) -> None:
"""Broadcast a message from an AI persona."""
await self.send(sender=ai_name, text=text, seat=seat, role=ChatRole.AI)
# ------------------------------------------------------------------
# History
# ------------------------------------------------------------------
def history(
self,
last_n: int = 50,
role_filter: Optional[ChatRole] = None,
) -> list[dict]:
msgs = self._history
if role_filter:
msgs = [m for m in msgs if m.role == role_filter]
return [m.to_dict() for m in msgs[-last_n:]]
def clear(self) -> None:
self._history.clear()
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _store(self, msg: ChatMessage) -> None:
self._history.append(msg)
if len(self._history) > MAX_HISTORY:
self._history = self._history[-MAX_HISTORY:]
async def _broadcast(self, msg: ChatMessage) -> None:
payload = msg.to_dict()
# Players
for send_fn in self._player_handlers.values():
try:
await send_fn(payload)
except Exception:
pass
# Spectators
for send_fn in self._spectator_handlers:
try:
await send_fn(payload)
except Exception:
pass
# Global
for send_fn in self._global_handlers:
try:
await send_fn(payload)
except Exception:
pass
-113
View File
@@ -1,113 +0,0 @@
"""
Commander AI Lab — Private Hand Bus (Phase 8)
=============================================
Ensures each player only receives their own private hand data.
The game engine publishes full state snapshots (including all hands)
to the PrivateHandBus. The bus strips all hands from the public
broadcast and sends each player only their own hand via a
dedicated per-seat WebSocket callback.
This design means:
- The public state broadcast never contains card-in-hand data
- Each player receives one extra message per state change: their own hand
- Spectators never see any hand data (enforced in SpectatorManager)
- AI opponent hands are never transmitted over the network
Usage::
bus = PrivateHandBus()
bus.register(seat=0, send_fn=player_ws_send)
bus.register(seat=1, send_fn=ai_seat_noop) # or skip entirely
# After each state change:
await bus.dispatch(full_state_snapshot, current_turn=5)
"""
from __future__ import annotations
import json
from typing import Callable, Awaitable, Optional
HandSendFn = Callable[[str], Awaitable[None]]
class PrivateHandBus:
"""
Routes private hand data to the correct seat only.
"""
def __init__(self) -> None:
self._handlers: dict[int, HandSendFn] = {}
self._human_seats: set[int] = set()
def register(
self,
seat: int,
send_fn: HandSendFn,
is_human: bool = True,
) -> None:
"""
Register a send callback for a seat.
Only human seats should have real callbacks; AI seats are skipped.
"""
self._handlers[seat] = send_fn
if is_human:
self._human_seats.add(seat)
def unregister(self, seat: int) -> None:
self._handlers.pop(seat, None)
self._human_seats.discard(seat)
async def dispatch(
self,
full_snapshot: dict,
current_turn: int,
) -> dict:
"""
Send each human seat their private hand, then return a
scrubbed public snapshot (all hand arrays removed).
Parameters
----------
full_snapshot : Full game state dict including all players' hands.
current_turn : Current game turn (included in hand payload).
Returns
-------
Public snapshot with all hand data stripped.
"""
players = full_snapshot.get("players", [])
for player in players:
seat = player.get("seat")
if seat not in self._human_seats:
continue
send_fn = self._handlers.get(seat)
if send_fn is None:
continue
hand_payload = json.dumps({
"type": "hand",
"seat": seat,
"turn": current_turn,
"cards": player.get("hand", []),
})
try:
await send_fn(hand_payload)
except Exception:
pass
# Strip all hands for public broadcast
return self._strip_hands(full_snapshot)
def registered_seats(self) -> list[int]:
return sorted(self._handlers.keys())
@staticmethod
def _strip_hands(snapshot: dict) -> dict:
import copy
safe = copy.deepcopy(snapshot)
for player in safe.get("players", []):
player.pop("hand", None)
return safe
-220
View File
@@ -1,220 +0,0 @@
"""
Commander AI Lab — LAN Discovery (Phase 8)
==========================================
UDP broadcast/listen system for discovering Commander AI Lab hosts
on the local network without manual IP entry.
Protocol:
- Host sends a UDP broadcast on LAN_PORT every BEACON_INTERVAL seconds
- Payload is a compact JSON beacon: {"app": "commander-ai-lab",
"version": "...", "room": ..., "host": ..., "port": int, "players": int}
- Clients listen on LAN_PORT, collect beacons, and surface them as
available games
- Beacons expire after BEACON_TTL seconds of silence from a host
Usage::
# On the host machine
discovery = LANDiscovery(mode="host", room_info={...})
await discovery.start()
...
await discovery.stop()
# On a client machine
discovery = LANDiscovery(mode="client")
await discovery.start()
beacons = discovery.discovered_hosts()
"""
from __future__ import annotations
import asyncio
import json
import socket
import time
from dataclasses import dataclass, field
from typing import Optional
APP_ID = "commander-ai-lab"
APP_VERSION = "0.8.0"
LAN_PORT = 42424
BEACON_INTERVAL = 3.0 # seconds between host broadcasts
BEACON_TTL = 10.0 # seconds before a beacon is considered stale
MAX_PACKET_SIZE = 2048
@dataclass
class LANBeacon:
host_ip: str
host_port: int
room_id: str
room_name: str
player_count: int
max_players: int
has_password: bool
version: str = APP_VERSION
last_seen: float = field(default_factory=time.time)
def is_stale(self) -> bool:
return (time.time() - self.last_seen) > BEACON_TTL
def to_dict(self) -> dict:
return {
"host_ip": self.host_ip,
"host_port": self.host_port,
"room_id": self.room_id,
"room_name": self.room_name,
"player_count": self.player_count,
"max_players": self.max_players,
"has_password": self.has_password,
"version": self.version,
"last_seen": self.last_seen,
}
class LANDiscovery:
"""
Manages host broadcasting and client listening for LAN game discovery.
Parameters
----------
mode : 'host' | 'client'
room_info : Required for mode='host'. Dict with room metadata.
bind_port : UDP port to use (default LAN_PORT).
game_port : TCP/WebSocket port the FastAPI server is listening on.
"""
def __init__(
self,
mode: str,
room_info: Optional[dict] = None,
bind_port: int = LAN_PORT,
game_port: int = 8000,
) -> None:
if mode not in ("host", "client"):
raise ValueError("mode must be 'host' or 'client'")
self.mode = mode
self.room_info = room_info or {}
self.bind_port = bind_port
self.game_port = game_port
self._running = False
self._task: Optional[asyncio.Task] = None
# client-side discovered hosts: host_ip -> LANBeacon
self._discovered: dict[str, LANBeacon] = {}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def start(self) -> None:
self._running = True
if self.mode == "host":
self._task = asyncio.create_task(self._broadcast_loop())
else:
self._task = asyncio.create_task(self._listen_loop())
async def stop(self) -> None:
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
def discovered_hosts(self) -> list[dict]:
"""Return all non-stale discovered hosts (client mode only)."""
self._prune_stale()
return [b.to_dict() for b in self._discovered.values()]
def update_room_info(self, room_info: dict) -> None:
"""Update broadcast payload (e.g. when player count changes)."""
self.room_info = room_info
# ------------------------------------------------------------------
# Host — broadcast loop
# ------------------------------------------------------------------
async def _broadcast_loop(self) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.setblocking(False)
try:
while self._running:
payload = self._build_beacon_payload()
data = json.dumps(payload).encode()
try:
sock.sendto(data, ("<broadcast>", self.bind_port))
except OSError:
pass
await asyncio.sleep(BEACON_INTERVAL)
finally:
sock.close()
def _build_beacon_payload(self) -> dict:
return {
"app": APP_ID,
"version": APP_VERSION,
"room_id": self.room_info.get("room_id", ""),
"room_name": self.room_info.get("room_name", "Unnamed Room"),
"player_count": self.room_info.get("player_count", 1),
"max_players": self.room_info.get("max_players", 4),
"has_password": self.room_info.get("has_password", False),
"host_port": self.game_port,
}
# ------------------------------------------------------------------
# Client — listen loop
# ------------------------------------------------------------------
async def _listen_loop(self) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setblocking(False)
try:
sock.bind(("", self.bind_port))
except OSError:
sock.close()
return
loop = asyncio.get_event_loop()
try:
while self._running:
try:
data, addr = await loop.run_in_executor(
None, lambda: sock.recvfrom(MAX_PACKET_SIZE)
)
self._handle_beacon(data, addr[0])
except (OSError, BlockingIOError):
await asyncio.sleep(0.1)
finally:
sock.close()
def _handle_beacon(
self,
data: bytes,
sender_ip: str,
) -> None:
try:
payload = json.loads(data.decode())
except (json.JSONDecodeError, UnicodeDecodeError):
return
if payload.get("app") != APP_ID:
return
beacon = LANBeacon(
host_ip=sender_ip,
host_port=payload.get("host_port", 8000),
room_id=payload.get("room_id", ""),
room_name=payload.get("room_name", "Unknown"),
player_count=payload.get("player_count", 1),
max_players=payload.get("max_players", 4),
has_password=payload.get("has_password", False),
version=payload.get("version", ""),
)
self._discovered[sender_ip] = beacon
def _prune_stale(self) -> None:
stale = [ip for ip, b in self._discovered.items() if b.is_stale()]
for ip in stale:
del self._discovered[ip]
-280
View File
@@ -1,280 +0,0 @@
"""
Commander AI Lab — Lobby Manager (Phase 8)
==========================================
Manages game rooms from creation through launch:
RoomState WAITING → READY → IN_PROGRESS → FINISHED
Flow:
1. Host calls LobbyManager.create_room() → returns room_id
2. Players join via LobbyManager.join_room(room_id, player_name)
3. Each player calls set_ready(room_id, seat) when prepared
4. When all human slots are ready, room transitions to READY
5. Host calls launch_game(room_id) → fires on_launch callback
6. Room moves to IN_PROGRESS; late-joins become spectators
AI slots are pre-filled and always considered ready.
"""
from __future__ import annotations
import secrets
import asyncio
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional, Callable, Awaitable
MAX_PLAYERS = 4
MAX_SPECTATORS = 20
ROOM_ID_BYTES = 6
class RoomState(Enum):
WAITING = auto()
READY = auto()
IN_PROGRESS = auto()
FINISHED = auto()
@dataclass
class LobbySlot:
seat: int
player_name: Optional[str] = None
is_ai: bool = False
ai_personality: Optional[str] = None
is_host: bool = False
is_ready: bool = False
connection_id: Optional[str] = None # WebSocket connection tag
@property
def is_filled(self) -> bool:
return self.player_name is not None or self.is_ai
def to_dict(self) -> dict:
return {
"seat": self.seat,
"player_name": self.player_name,
"is_ai": self.is_ai,
"ai_personality": self.ai_personality,
"is_host": self.is_host,
"is_ready": self.is_ready,
}
# Callback fired when a room is launched: async fn(room_id, room)
LaunchCallback = Callable[[str, "LobbyRoom"], Awaitable[None]]
@dataclass
class LobbyRoom:
room_id: str
room_name: str
max_players: int = MAX_PLAYERS
state: RoomState = RoomState.WAITING
slots: list[LobbySlot] = field(default_factory=list)
spectators: list[str] = field(default_factory=list) # connection_ids
password: Optional[str] = None
chat: Optional[object] = None # ChatChannel injected post-construction
created_at: float = field(default_factory=lambda: __import__('time').time())
launched_at: Optional[float] = None
def __post_init__(self):
if not self.slots:
self.slots = [LobbySlot(seat=i) for i in range(self.max_players)]
# ------------------------------------------------------------------
# Slot helpers
# ------------------------------------------------------------------
def open_seats(self) -> list[int]:
return [s.seat for s in self.slots if not s.is_filled]
def human_seats(self) -> list[int]:
return [s.seat for s in self.slots if s.is_filled and not s.is_ai]
def all_humans_ready(self) -> bool:
humans = [s for s in self.slots if s.is_filled and not s.is_ai]
return bool(humans) and all(s.is_ready for s in humans)
def all_seats_filled(self) -> bool:
return all(s.is_filled for s in self.slots)
def get_slot(self, seat: int) -> Optional[LobbySlot]:
return next((s for s in self.slots if s.seat == seat), None)
def to_dict(self) -> dict:
return {
"room_id": self.room_id,
"room_name": self.room_name,
"state": self.state.name,
"slots": [s.to_dict() for s in self.slots],
"spectators": len(self.spectators),
"has_password": self.password is not None,
}
class LobbyManager:
"""
Singleton-style registry of all active lobby rooms.
Usage::
mgr = LobbyManager()
rid = await mgr.create_room("Friday Night Commander", host_name="Alice")
seat = await mgr.join_room(rid, "Bob")
await mgr.set_ready(rid, seat)
await mgr.launch_game(rid)
"""
def __init__(
self,
on_launch: Optional[LaunchCallback] = None,
) -> None:
self._rooms: dict[str, LobbyRoom] = {}
self._on_launch = on_launch
# ------------------------------------------------------------------
# Room lifecycle
# ------------------------------------------------------------------
async def create_room(
self,
room_name: str,
host_name: str,
max_players: int = MAX_PLAYERS,
ai_slots: Optional[list[dict]] = None, # [{seat:int, personality:str}, ...]
password: Optional[str] = None,
) -> str:
"""
Create a new lobby room. Returns room_id.
Host occupies seat 0. AI slots are pre-filled.
"""
room_id = secrets.token_hex(ROOM_ID_BYTES)
room = LobbyRoom(
room_id=room_id,
room_name=room_name,
max_players=max_players,
password=password,
)
# Fill host into seat 0
room.slots[0].player_name = host_name
room.slots[0].is_host = True
room.slots[0].is_ready = False
# Fill AI slots
for ai in (ai_slots or []):
seat = ai.get("seat")
if seat is None or seat >= max_players:
continue
slot = room.get_slot(seat)
if slot and not slot.is_filled:
slot.is_ai = True
slot.player_name = f"AI-{ai.get('personality','spike').title()}"
slot.ai_personality = ai.get("personality", "spike")
slot.is_ready = True # AI always ready
self._rooms[room_id] = room
return room_id
async def join_room(
self,
room_id: str,
player_name: str,
password: Optional[str] = None,
connection_id: Optional[str] = None,
) -> int:
"""
Join an existing room. Returns the assigned seat number.
Raises ValueError on invalid room / wrong password / full room.
"""
room = self._get_room(room_id)
if room.state != RoomState.WAITING:
raise ValueError("Room is not accepting new players.")
if room.password and room.password != password:
raise ValueError("Incorrect room password.")
open_seats = room.open_seats()
if not open_seats:
raise ValueError("Room is full.")
seat = open_seats[0]
slot = room.get_slot(seat)
slot.player_name = player_name
slot.connection_id = connection_id
return seat
async def add_spectator(
self,
room_id: str,
connection_id: str,
) -> None:
"""Add a spectator connection to a room."""
room = self._get_room(room_id)
if len(room.spectators) >= MAX_SPECTATORS:
raise ValueError("Spectator limit reached.")
if connection_id not in room.spectators:
room.spectators.append(connection_id)
async def set_ready(
self,
room_id: str,
seat: int,
ready: bool = True,
) -> RoomState:
"""Mark a seat as ready. Returns the new room state."""
room = self._get_room(room_id)
slot = room.get_slot(seat)
if slot is None:
raise ValueError(f"Seat {seat} not found.")
slot.is_ready = ready
if room.all_humans_ready() and room.all_seats_filled():
room.state = RoomState.READY
else:
room.state = RoomState.WAITING
return room.state
async def launch_game(
self,
room_id: str,
) -> LobbyRoom:
"""Launch the game. Room must be in READY state."""
room = self._get_room(room_id)
if room.state != RoomState.READY:
raise ValueError(f"Room not ready (state={room.state.name}).")
room.state = RoomState.IN_PROGRESS
room.launched_at = __import__('time').time()
if self._on_launch:
await self._on_launch(room_id, room)
return room
async def finish_room(
self,
room_id: str,
) -> None:
room = self._get_room(room_id)
room.state = RoomState.FINISHED
# ------------------------------------------------------------------
# Queries
# ------------------------------------------------------------------
def get_room(self, room_id: str) -> Optional[LobbyRoom]:
return self._rooms.get(room_id)
def list_open_rooms(self) -> list[dict]:
return [
r.to_dict() for r in self._rooms.values()
if r.state == RoomState.WAITING
]
def list_all_rooms(self) -> list[dict]:
return [r.to_dict() for r in self._rooms.values()]
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _get_room(self, room_id: str) -> LobbyRoom:
room = self._rooms.get(room_id)
if room is None:
raise ValueError(f"Room '{room_id}' not found.")
return room
@@ -1,147 +0,0 @@
"""
Commander AI Lab — Spectator Manager (Phase 8)
===============================================
Provides read-only game-state feeds to spectator WebSocket connections.
Features:
- Spectators receive the same public state snapshots as players
- Private hands are NEVER sent to spectators
- Spectators are tracked per room; dead connections are pruned
- Optional delay_turns: hide state N turns behind live game
(prevents spectators from feeding information to players)
- SpectatorManager.broadcast() is called by the WebSocket router
after every state change
"""
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass, field
from typing import Callable, Awaitable, Any, Optional
BroadcastFn = Callable[[str], Awaitable[None]]
@dataclass
class SpectatorConnection:
connection_id: str
send_fn: BroadcastFn
room_id: str
joined_at: float = field(default_factory=lambda: __import__('time').time())
delay_turns: int = 0
alive: bool = True
class SpectatorManager:
"""
Registry of active spectator connections per room.
Usage::
mgr = SpectatorManager()
mgr.add(room_id, connection_id, send_fn)
await mgr.broadcast(room_id, state_snapshot)
mgr.remove(room_id, connection_id)
"""
def __init__(self) -> None:
# room_id -> {connection_id -> SpectatorConnection}
self._rooms: dict[str, dict[str, SpectatorConnection]] = {}
def add(
self,
room_id: str,
connection_id: str,
send_fn: BroadcastFn,
delay_turns: int = 0,
) -> None:
self._rooms.setdefault(room_id, {})
self._rooms[room_id][connection_id] = SpectatorConnection(
connection_id=connection_id,
send_fn=send_fn,
room_id=room_id,
delay_turns=delay_turns,
)
def remove(
self,
room_id: str,
connection_id: str,
) -> None:
self._rooms.get(room_id, {}).pop(connection_id, None)
def count(self, room_id: str) -> int:
return len(self._rooms.get(room_id, {}))
async def broadcast(
self,
room_id: str,
snapshot: dict,
) -> None:
"""
Send a public state snapshot to all spectators of room_id.
Private hand data must be stripped from snapshot before calling.
Dead connections are pruned automatically.
"""
room_conns = self._rooms.get(room_id, {})
if not room_conns:
return
# Ensure no private hand data leaks
safe = self._strip_hands(snapshot)
payload = json.dumps({"type": "spectate", "data": safe})
dead: list[str] = []
for cid, conn in room_conns.items():
if not conn.alive:
dead.append(cid)
continue
try:
await conn.send_fn(payload)
except Exception:
conn.alive = False
dead.append(cid)
for cid in dead:
room_conns.pop(cid, None)
async def broadcast_chat(
self,
room_id: str,
chat_payload: dict,
) -> None:
"""Forward a chat message to all spectators of room_id."""
room_conns = self._rooms.get(room_id, {})
payload = json.dumps({"type": "chat", "data": chat_payload})
dead: list[str] = []
for cid, conn in room_conns.items():
try:
await conn.send_fn(payload)
except Exception:
dead.append(cid)
for cid in dead:
room_conns.pop(cid, None)
def list_spectators(
self,
room_id: str,
) -> list[str]:
return list(self._rooms.get(room_id, {}).keys())
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
@staticmethod
def _strip_hands(snapshot: dict) -> dict:
"""
Remove private hand arrays from a state snapshot dict.
Preserves hand_count for display purposes.
"""
import copy
safe = copy.deepcopy(snapshot)
for player in safe.get("players", []):
player.pop("hand", None)
return safe
-521
View File
@@ -1,521 +0,0 @@
"""
Commander AI Lab — AIOpponent (Phase 2)
========================================
High-level AI player that wraps DeepSeekBrain + Phase 1 prompt builder
+ personality prompts to produce per-turn decisions and narration.
Design:
- Each seat gets one AIOpponent instance (supports 2–4 simultaneous).
- decide_action() builds the full LLM prompt using Phase 1 helpers,
calls GPT-OSS 20B via Ollama, parses the response, validates the
action index against legal_moves, and falls back to heuristic on failure.
- narrate_play() calls the LLM with a short narration prompt and
enforces a 30-word cap to prevent UI overflow.
- All fallback events are logged via StateLogger for tuning.
Usage:
from commander_ai_lab.sim.ai_opponent import AIOpponent
from commander_ai_lab.sim.personality_prompts import AGGRO_TIMMY
ai = AIOpponent(
name="Timmy",
seat=1,
personality=AGGRO_TIMMY,
deck=[...], # list[Card]
)
ai.brain.check_connection()
move_id = ai.decide_action(game_state, legal_moves)
flavor = ai.narrate_play(move_id, legal_moves)
"""
from __future__ import annotations
import json
import logging
import re
import time
from dataclasses import dataclass, field
from typing import Optional
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
from commander_ai_lab.sim.game_state import CommanderGameState
from commander_ai_lab.sim.personality_prompts import Personality, AGGRO_TIMMY
from commander_ai_lab.sim.prompt_builder import build_full_prompt
from commander_ai_lab.sim.state_logger import StateLogger
logger = logging.getLogger("ai_opponent")
_MAX_NARRATION_WORDS = 30
# ── Memory Log Entry ────────────────────────────────────────────────
@dataclass
class MemoryEntry:
"""One entry in the AI's short-term memory log."""
turn: int
phase: str
chosen_move_id: Optional[int]
move_description: str
narration: str
fallback_used: bool
latency_ms: float
# ── AIOpponent ─────────────────────────────────────────────────────────
@dataclass
class AIOpponent:
"""
A single AI seat in a Commander game.
Parameters
----------
name : str
Display name shown in the UI.
seat : int
Seat index in the game (0–3).
personality : Personality
One of the four archetype personalities from personality_prompts.py.
deck : list
List of Card objects for this player's deck (used for deck context).
brain_config : DeepSeekConfig | None
LLM configuration. Defaults to GPT-OSS 20B via Ollama localhost.
log_dir : str | None
Directory for StateLogger snapshots. Defaults to logs/snapshots/.
"""
name: str
seat: int
personality: Personality = field(default_factory=lambda: AGGRO_TIMMY)
deck: list = field(default_factory=list)
brain_config: Optional[DeepSeekConfig] = None
log_dir: Optional[str] = None
# Populated post-init
brain: DeepSeekBrain = field(init=False)
state_logger: StateLogger = field(init=False)
memory_log: list[MemoryEntry] = field(init=False, default_factory=list)
# Stats
_total_decisions: int = field(init=False, default=0)
_total_fallbacks: int = field(init=False, default=0)
def __post_init__(self) -> None:
self.brain = DeepSeekBrain(config=self.brain_config)
self.state_logger = StateLogger(log_dir=self.log_dir)
# ── Core API ─────────────────────────────────────────────────
def decide_action(
self,
game_state: CommanderGameState,
legal_moves: list[dict],
) -> Optional[int]:
"""
Choose a legal move for this AI seat.
Workflow:
1. Build the full LLM prompt (Phase 1 helpers + personality).
2. Send to GPT-OSS 20B via DeepSeekBrain.
3. Parse LLM response for a move index.
4. Validate the index is in legal_moves.
5. On failure, fall back to heuristic and log the event.
Args:
game_state: Current CommanderGameState.
legal_moves: List of legal move dicts from the rules engine.
Each dict: {"id": int, "category": str, "description": str, ...}
Returns:
int: The chosen move's "id" value, or None if no legal moves.
"""
if not legal_moves:
return None
t_start = time.time()
self._total_decisions += 1
fallback_used = False
chosen_id: Optional[int] = None
# Build the complete prompt
prompt = build_full_prompt(
gs=game_state,
viewer_seat=self.seat,
moves=legal_moves,
personality=self.personality.system_prompt,
)
# ── Attempt LLM decision ────────────────────────────────
if self.brain._connected:
try:
chosen_id = self._call_llm_for_move(prompt, legal_moves)
except Exception as exc:
logger.warning(
"[%s seat=%d] LLM decision failed: %s — using fallback",
self.name, self.seat, exc,
)
chosen_id = None
# ── Fallback if LLM failed or not connected ────────────────
if chosen_id is None:
fallback_used = True
self._total_fallbacks += 1
chosen_id = self._heuristic_move(game_state, legal_moves)
logger.info(
"[%s seat=%d] Fallback triggered. Chose move id=%s",
self.name, self.seat, chosen_id,
)
latency_ms = round((time.time() - t_start) * 1000, 1)
move_desc = self._move_description(chosen_id, legal_moves)
# ── Save snapshot for debugging ───────────────────────────
try:
self.state_logger.save(
game_state=game_state,
prompt=prompt,
chosen_move_id=chosen_id,
seat=self.seat,
fallback_used=fallback_used,
extra={"latency_ms": latency_ms, "personality": self.personality.key},
)
except Exception as log_exc:
logger.warning("[%s] State logger failed: %s", self.name, log_exc)
# ── Memory log ────────────────────────────────────────────
self.memory_log.append(MemoryEntry(
turn=game_state.turn,
phase=game_state.current_phase,
chosen_move_id=chosen_id,
move_description=move_desc,
narration="", # filled in by narrate_play()
fallback_used=fallback_used,
latency_ms=latency_ms,
))
return chosen_id
def narrate_play(
self,
move_id: Optional[int],
legal_moves: list[dict],
game_state: Optional[CommanderGameState] = None,
) -> str:
"""
Generate a short in-character flavor line for the chosen action.
Calls GPT-OSS 20B with a lightweight narration prompt.
Falls back to a canned line if the LLM is unavailable.
Always enforces the 30-word cap.
Args:
move_id: The chosen move id (from decide_action).
legal_moves: Full legal moves list (to look up description).
game_state: Optional state for richer context.
Returns:
A narration string of at most 30 words.
"""
move_desc = self._move_description(move_id, legal_moves)
narration = ""
if self.brain._connected:
try:
narration = self._call_llm_for_narration(move_desc)
except Exception as exc:
logger.debug("[%s] Narration LLM failed: %s", self.name, exc)
if not narration:
narration = self._canned_narration(move_desc)
# Enforce word cap
words = narration.split()
if len(words) > _MAX_NARRATION_WORDS:
narration = " ".join(words[:_MAX_NARRATION_WORDS]) + "…"
# Back-fill narration into most recent memory entry
if self.memory_log:
self.memory_log[-1].narration = narration
return narration
# ── LLM Helpers ────────────────────────────────────────────────
def _call_llm_for_move(
self,
prompt: str,
legal_moves: list[dict],
) -> Optional[int]:
"""
Send the full prompt to the LLM and parse a move index from the response.
Returns the move id or None if parsing fails.
"""
from urllib.request import Request, urlopen
import json as _json
cfg = self.brain.config
payload = {
"model": cfg.model,
"messages": [
{"role": "user", "content": prompt},
],
"temperature": cfg.temperature,
"max_tokens": 64, # We only need a number
"top_p": cfg.top_p,
"stream": False,
}
url = cfg.api_base.rstrip("/") + "/v1/chat/completions"
req_data = _json.dumps(payload).encode("utf-8")
req = Request(url, data=req_data, method="POST")
req.add_header("Content-Type", "application/json")
with urlopen(req, timeout=cfg.request_timeout) as resp:
resp_data = _json.loads(resp.read())
choices = resp_data.get("choices", [])
if not choices:
return None
raw = choices[0].get("message", {}).get("content", "").strip()
return self._parse_move_id(raw, legal_moves)
def _call_llm_for_narration(self, move_desc: str) -> str:
"""
Call the LLM with a short narration prompt.
Returns raw narration text (word-capping applied in narrate_play).
"""
from urllib.request import Request, urlopen
import json as _json
narration_prompt = (
f"{self.personality.system_prompt}\n\n"
f"You just played: {move_desc}\n"
f"Give a single in-character reaction of at most {_MAX_NARRATION_WORDS} words. "
f"No explanation. Just the flavor line."
)
cfg = self.brain.config
payload = {
"model": cfg.model,
"messages": [{"role": "user", "content": narration_prompt}],
"temperature": 0.8, # Higher temperature for creative narration
"max_tokens": 80,
"stream": False,
}
url = cfg.api_base.rstrip("/") + "/v1/chat/completions"
req_data = _json.dumps(payload).encode("utf-8")
req = Request(url, data=req_data, method="POST")
req.add_header("Content-Type", "application/json")
with urlopen(req, timeout=10.0) as resp:
resp_data = _json.loads(resp.read())
choices = resp_data.get("choices", [])
if not choices:
return ""
return choices[0].get("message", {}).get("content", "").strip()
# ── Response Parsing ───────────────────────────────────────────────
def _parse_move_id(
self,
raw: str,
legal_moves: list[dict],
) -> Optional[int]:
"""
Extract a valid move id from the LLM's raw text response.
Strategies (in order):
1. Raw text is just a number → use it if valid.
2. First integer found in text → use it if valid.
3. JSON with an 'id' or 'action' key → extract and validate.
4. Return None (triggers fallback).
"""
valid_ids = {m["id"] for m in legal_moves}
# Strip think blocks (reasoning models)
raw = re.sub(r'<think>.*?</think>', '', raw, flags=re.DOTALL).strip()
# Strategy 1: whole response is a number
stripped = raw.strip()
if stripped.isdigit():
mid = int(stripped)
if mid in valid_ids:
return mid
# Strategy 2: first integer in text
nums = re.findall(r'\b(\d+)\b', raw)
for n in nums:
mid = int(n)
if mid in valid_ids:
return mid
# Strategy 3: JSON object with id/action key
json_match = re.search(r'\{.*?\}', raw, re.DOTALL)
if json_match:
try:
parsed = json.loads(json_match.group(0))
for key in ("id", "move_id", "choice", "action"):
if key in parsed:
mid = int(parsed[key])
if mid in valid_ids:
return mid
except (json.JSONDecodeError, ValueError, TypeError):
pass
logger.debug("[%s] Could not parse move id from: %r", self.name, raw[:100])
return None
# ── Heuristic Fallback ───────────────────────────────────────────────
def _heuristic_move(self, game_state: CommanderGameState, legal_moves: list[dict]) -> int:
"""
Priority-based heuristic fallback.
Priority order (personality-agnostic safe default):
1. play_land
2. cast_commander
3. cast_spell
4. attack
5. activate_ability
6. pass_priority / pass_turn
7. Any other move (first available)
"""
priority_categories = [
"play_land",
"cast_commander",
"cast_spell",
"attack",
"activate_ability",
"pass_priority",
"pass_turn",
]
moves_by_cat: dict[str, list[dict]] = {}
for m in legal_moves:
cat = m.get("category", "other")
moves_by_cat.setdefault(cat, []).append(m)
for cat in priority_categories:
if cat in moves_by_cat:
return moves_by_cat[cat][0]["id"]
# Absolute last resort
return legal_moves[0]["id"]
# ── Canned Narration ───────────────────────────────────────────────
_CANNED: dict[str, dict[str, str]] = {
"aggro_timmy": {
"play_land": "More mana, more mayhem!",
"cast_spell": "Here comes the pain!",
"attack": "ATTACK! Take everything!",
"pass_priority": "Fine, go… but I’m watching you.",
"default": "Let’s smash something!",
},
"control_spike": {
"play_land": "Mana secured. Proceeding.",
"cast_spell": "Optimal play. As expected.",
"attack": "Calculated aggression — acceptable risk.",
"pass_priority": "Holding interaction mana. Predictable of you.",
"default": "Inevitable victory approaches.",
},
"combo_johnny": {
"play_land": "One step closer to the combo!",
"cast_spell": "Assembling the pieces…",
"attack": "Buying time until the combo fires.",
"pass_priority": "Patience. The combo is coming.",
"default": "The pieces are falling into place!",
},
"political_negotiator": {
"play_land": "Just tapping a land. Nothing threatening here.",
"cast_spell": "This helps us all, really.",
"attack": "I’m afraid I must — for the good of the table.",
"pass_priority": "I’ll let this resolve. For now.",
"default": "Let’s all be reasonable about this.",
},
}
def _canned_narration(self, move_desc: str) -> str:
"""Return a canned flavor line matching personality and move category."""
canned = self._CANNED.get(self.personality.key, {})
desc_lower = move_desc.lower()
for key in ("play_land", "cast_spell", "attack", "pass_priority"):
if key.replace("_", " ") in desc_lower or key in desc_lower:
return canned.get(key, canned.get("default", "Interesting move."))
return canned.get("default", "Interesting move.")
# ── Utilities ──────────────────────────────────────────────────────────
@staticmethod
def _move_description(move_id: Optional[int], legal_moves: list[dict]) -> str:
if move_id is None:
return "(no move)"
for m in legal_moves:
if m["id"] == move_id:
return m.get("description", f"Move {move_id}")
return f"Move {move_id}"
def get_stats(self) -> dict:
"""Return per-opponent decision statistics."""
return {
"name": self.name,
"seat": self.seat,
"personality": self.personality.key,
"total_decisions": self._total_decisions,
"total_fallbacks": self._total_fallbacks,
"fallback_rate": (
round(self._total_fallbacks / max(self._total_decisions, 1), 3)
),
"memory_entries": len(self.memory_log),
"llm_stats": self.brain.get_stats(),
}
def new_game(self) -> None:
"""Reset per-game state for a new game."""
self.memory_log.clear()
self._total_decisions = 0
self._total_fallbacks = 0
self.brain.new_game(ai_player_index=self.seat)
# ── Factory ───────────────────────────────────────────────────────────────────
def create_four_player_ai_roster(
names: list[str] | None = None,
brain_config: DeepSeekConfig | None = None,
log_dir: str | None = None,
) -> list[AIOpponent]:
"""
Create a standard 4-player AI roster with one of each personality.
Args:
names: Optional list of 4 display names. Defaults to personality display names.
brain_config: Shared LLM config for all opponents.
log_dir: Shared snapshot log directory.
Returns:
List of 4 AIOpponent instances (seats 0–3).
"""
from commander_ai_lab.sim.personality_prompts import (
AGGRO_TIMMY, CONTROL_SPIKE, COMBO_JOHNNY, POLITICAL_NEGOTIATOR
)
archetypes = [AGGRO_TIMMY, CONTROL_SPIKE, COMBO_JOHNNY, POLITICAL_NEGOTIATOR]
default_names = [p.display_name for p in archetypes]
display_names = names if (names and len(names) == 4) else default_names
return [
AIOpponent(
name=display_names[i],
seat=i,
personality=archetypes[i],
brain_config=brain_config,
log_dir=log_dir,
)
for i in range(4)
]
+166 -164
View File
@@ -1,28 +1,23 @@
"""
Commander AI Lab — LLM Brain for AI Opponent
=============================================
Integrates a local LLM (GPT-OSS 20B Q4_K_M via Ollama, or any
OpenAI-compatible endpoint) as the decision-making "brain" for the
AI opponent in the headless simulator.
Commander AI Lab — DeepSeek LLM Brain for AI Opponent
======================================================
Integrates a local LLM (DeepSeek-R1 via LM Studio / Ollama) as the
decision-making "brain" for the AI opponent in the headless simulator.
The LLM receives a structured game-state snapshot and returns a JSON
action describing what to do this turn (play land, cast spell, attack,
hold/pass, use removal, use board wipe, cast ramp, cast commander).
Default target: GPT-OSS 20B (Q4_K_M) via Ollama at localhost:11434
ollama run gpt-oss:20b
Also supports:
- LM Studio (OpenAI-compatible API) — set api_base to LM Studio URL
Supports:
- LM Studio (OpenAI-compatible API) — default at http://192.168.0.122:1234
- Ollama (OpenAI-compatible mode) — http://localhost:11434
- Any OpenAI-compatible endpoint
Performance features:
- Configurable timeout with fallback to heuristic
- Response caching for identical game states
- Structured JSON output with validation
- Decision logging (JSONL) for RL training data
Each JSONL entry includes game_result (winner_seat, winner_name,
player_lives, reward) once flush_log() is called with a GameResult.
- Decision logging (JSONL) for future RL training data
"""
from __future__ import annotations
@@ -49,59 +44,43 @@ logger = logging.getLogger("deepseek_brain")
@dataclass
class DeepSeekConfig:
"""
Configuration for the LLM brain.
"""Configuration for the DeepSeek LLM brain."""
Defaults target GPT-OSS 20B (Q4_K_M) served by Ollama on localhost.
Override api_base / model to point at LM Studio or any other
OpenAI-compatible endpoint.
Example — LM Studio::
config = DeepSeekConfig(
api_base="http://192.168.0.122:1234",
model="deepseek-r1-distill-qwen-8b",
max_tokens=2048, # R1 needs room for <think> blocks
)
Example — Ollama (default)::
config = DeepSeekConfig() # points at localhost:11434 / gpt-oss:20b
"""
# API endpoint — Ollama OpenAI-compatible base (NO /v1 suffix)
api_base: str = "http://localhost:11434"
# Model name as registered in Ollama
model: str = "gpt-oss:20b"
# API endpoint (OpenAI-compatible)
api_base: str = "http://192.168.0.122:1234"
model: str = "deepseek-r1-distill-qwen-8b" # LM Studio model name
# Generation parameters
# GPT-OSS 20B does not emit <think> blocks so 1024 tokens is ample
temperature: float = 0.3
max_tokens: int = 1024
temperature: float = 0.3 # Low for more deterministic play
max_tokens: int = 2048 # R1 needs room for <think> reasoning + JSON response
top_p: float = 0.9
# Timeouts
request_timeout: float = 30.0 # Ollama local inference; raise vs LM Studio
fallback_on_timeout: bool = True
request_timeout: float = 10.0 # seconds per LLM call
fallback_on_timeout: bool = True # use heuristic if LLM times out
# Caching
cache_enabled: bool = True
cache_max_size: int = 256
cache_max_size: int = 256 # LRU cache entries
# Logging
log_decisions: bool = True
log_dir: str = ""
log_dir: str = "" # Set at runtime
# Retry
max_retries: int = 1
max_retries: int = 1 # Retry once on parse failure
# ══════════════════════════════════════════════════════════════
# Game-State Schema (Step 2) — Full Intelligence Snapshot
# ══════════════════════════════════════════════════════════════
# Basic land → color mapping for mana color tracking
_LAND_COLOR_MAP = {
"plains": "W", "island": "U", "swamp": "B", "mountain": "R", "forest": "G",
}
# Known combo/synergy patterns detected from oracle text
_COMBO_PATTERNS = [
("infinite", "Infinite combo piece"),
("you win the game", "Alternate win condition"),
@@ -120,29 +99,33 @@ _COMBO_PATTERNS = [
def build_game_state_snapshot(
sim_state,
player_index: int,
sim_state, # SimState
player_index: int, # Which player is the AI (0 or 1)
turn: int,
deck_context: dict | None = None,
deck_context: dict | None = None, # Full deck intelligence
) -> dict:
"""
Build a comprehensive game-state dict for the LLM with full intelligence.
deck_context (optional):
commander_name, commander_zone, color_identity, archetype,
full_decklist, combo_pieces, win_rate_history, deck_size
"""
me = sim_state.players[player_index]
opp = sim_state.players[1 - player_index]
ctx = deck_context or {}
# ── Mana by Color (Enhancement #8) ──────────────────────────
mana_total = 0
mana_by_color = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
my_bf = sim_state.get_battlefield(player_index)
opp_bf = sim_state.get_battlefield(1 - player_index)
for c in my_bf:
if not c.tapped and c.is_land():
for c in sim_state.battlefield:
if c.owner_id == player_index and not c.tapped and c.is_land():
mana_total += 1
land_name = c.name.lower().strip()
if land_name in _LAND_COLOR_MAP:
mana_by_color[_LAND_COLOR_MAP[land_name]] += 1
elif c.oracle_text:
# Parse oracle text for mana production
oracle_lower = c.oracle_text.lower()
if "any color" in oracle_lower:
for clr in "WUBRG":
@@ -153,12 +136,14 @@ def build_game_state_snapshot(
if sym in oracle_lower or f"add {clr.lower()}" in oracle_lower:
mana_by_color[clr] += 1
if not any(mana_by_color[k] for k in "WUBRG"):
mana_by_color["C"] += 1
mana_by_color["C"] += 1 # colorless
else:
mana_by_color["C"] += 1
# Only include colors that have sources
mana_colors = {k: v for k, v in mana_by_color.items() if v > 0}
# ── My Hand — Full Oracle Text (Enhancement #5) ────────────
my_hand = []
for c in me.hand:
card_info = {
@@ -170,6 +155,7 @@ def build_game_state_snapshot(
card_info["pt"] = c.pt
if c.keywords:
card_info["keywords"] = c.keywords[:5]
# Role detection
roles = []
if c.is_removal:
roles.append("removal")
@@ -181,16 +167,24 @@ def build_game_state_snapshot(
roles.append("commander")
if roles:
card_info["roles"] = roles
# Full oracle text — no truncation (Enhancement #5)
if c.oracle_text:
card_info["oracle"] = c.oracle_text
# Castability check against available mana
card_info["castable"] = (c.cmc or 0) <= mana_total and not c.is_land()
my_hand.append(card_info)
# ── My Board — with keywords and oracle for synergy ───────
my_board = []
for c in my_bf:
for c in sim_state.battlefield:
if c.owner_id != player_index:
continue
if c.is_land():
continue
entry = {"name": c.name, "type": _short_type(c.type_line)}
entry = {
"name": c.name,
"type": _short_type(c.type_line),
}
if c.pt:
entry["pt"] = c.pt
if c.tapped:
@@ -201,14 +195,20 @@ def build_game_state_snapshot(
entry["is_commander"] = True
my_board.append(entry)
# ── Opponent's Board — with Threat Assessment (Enhancement #9)
opp_board = []
opp_total_power = 0
opp_evasion_count = 0
opp_protected_count = 0
for c in opp_bf:
for c in sim_state.battlefield:
if c.owner_id != (1 - player_index):
continue
if c.is_land():
continue
entry = {"name": c.name, "type": _short_type(c.type_line)}
entry = {
"name": c.name,
"type": _short_type(c.type_line),
}
if c.pt:
entry["pt"] = c.pt
opp_total_power += c.get_power()
@@ -223,6 +223,7 @@ def build_game_state_snapshot(
opp_protected_count += 1
opp_board.append(entry)
# Threat level summary
if opp_total_power >= me.life:
threat_level = "LETHAL"
elif opp_total_power >= me.life * 0.5:
@@ -234,14 +235,19 @@ def build_game_state_snapshot(
else:
threat_level = "NONE"
# ── Graveyard Contents (Enhancement #6) ────────────────────
my_gy = [{"name": c.name, "type": _short_type(c.type_line)} for c in me.graveyard[:15]]
opp_gy = [{"name": c.name, "type": _short_type(c.type_line)} for c in opp.graveyard[:10]]
# ── Draw Tracking (Enhancement #7) ─────────────────────────
total_deck_size = ctx.get("deck_size", 100)
cards_seen = len(me.hand) + len(me.graveyard) + len(my_bf)
cards_seen = len(me.hand) + len(me.graveyard) + sum(
1 for c in sim_state.battlefield if c.owner_id == player_index
)
cards_remaining = len(me.library)
pct_drawn = round(cards_seen / max(total_deck_size, 1) * 100)
# Key cards still in library (if we know the decklist)
key_cards_in_library = []
if ctx.get("key_cards"):
library_names = {c.name.lower() for c in me.library}
@@ -249,6 +255,7 @@ def build_game_state_snapshot(
if kc.lower() in library_names:
key_cards_in_library.append(kc)
# ── Build the snapshot ─────────────────────────────────────
snapshot = {
"turn": turn + 1,
"my_life": me.life,
@@ -271,33 +278,43 @@ def build_game_state_snapshot(
"deck_drawn_pct": pct_drawn,
}
# ── Commander Identity (Enhancement #2) ────────────────────
if ctx.get("commander_name"):
# Determine commander zone
cmd_name_lower = ctx["commander_name"].lower()
cmd_zone = "library"
cmd_zone = "library" # default
for c in me.hand:
if c.name.lower() == cmd_name_lower:
cmd_zone = "hand"
break
for c in my_bf:
if c.name.lower() == cmd_name_lower:
for c in sim_state.battlefield:
if c.owner_id == player_index and c.name.lower() == cmd_name_lower:
cmd_zone = "battlefield"
break
for c in me.graveyard:
if c.name.lower() == cmd_name_lower:
cmd_zone = "graveyard"
break
snapshot["commander"] = {
"name": ctx["commander_name"],
"zone": cmd_zone,
"color_identity": ctx.get("color_identity", []),
}
# ── Full Decklist Awareness (Enhancement #1) ───────────────
if ctx.get("deck_summary"):
snapshot["deck_summary"] = ctx["deck_summary"]
# ── Win Conditions & Combos (Enhancement #3) ───────────────
if ctx.get("combo_pieces"):
snapshot["combo_pieces"] = ctx["combo_pieces"]
# ── Key Cards in Library (Enhancement #7) ──────────────────
if key_cards_in_library:
snapshot["key_cards_in_library"] = key_cards_in_library[:10]
# ── Historical Win Rate (Enhancement #10) ──────────────────
if ctx.get("win_rate") is not None:
snapshot["historical_win_rate"] = ctx["win_rate"]
@@ -305,6 +322,7 @@ def build_game_state_snapshot(
def _short_type(type_line: str) -> str:
"""Shorten type_line for token efficiency."""
if not type_line:
return "?"
tl = type_line.lower()
@@ -326,21 +344,27 @@ def _short_type(type_line: str) -> str:
def build_deck_context(
full_deck: list,
full_deck: list, # list[Card] — the full original deck
commander_name: str = "",
color_identity: list[str] | None = None,
archetype: str = "midrange",
win_rate: float | None = None,
) -> dict:
"""
Build a deck intelligence context dict from the full decklist.
Called once before a game starts, then passed to build_game_state_snapshot each turn.
"""
ctx: dict[str, Any] = {
"commander_name": commander_name,
"color_identity": color_identity or [],
"archetype": archetype,
"deck_size": len(full_deck),
}
if win_rate is not None:
ctx["win_rate"] = win_rate
# ── Deck Composition Summary (Enhancement #1) ──────────────
type_counts: dict[str, int] = {}
roles: dict[str, list[str]] = {"removal": [], "ramp": [], "board_wipe": [], "draw": []}
key_cards: list[str] = []
@@ -351,25 +375,32 @@ def build_deck_context(
for card in full_deck:
ctype = _short_type(card.type_line) if card.type_line else "?"
type_counts[ctype] = type_counts.get(ctype, 0) + 1
if not card.is_land():
total_cmc += card.cmc or 0
non_land_count += 1
# Track roles
if card.is_removal:
roles["removal"].append(card.name)
if card.is_board_wipe:
roles["board_wipe"].append(card.name)
if card.is_ramp:
roles["ramp"].append(card.name)
# Detect draw engines
oracle = (card.oracle_text or "").lower()
if re.search(r"draw .* cards?", oracle):
roles["draw"].append(card.name)
# ── Win Conditions & Combos (Enhancement #3) ──────────
for pattern, label in _COMBO_PATTERNS:
if re.search(pattern, oracle):
combo_pieces.append({"name": card.name, "role": label})
key_cards.append(card.name)
break
break # one label per card
avg_cmc = round(total_cmc / max(non_land_count, 1), 1)
ctx["deck_summary"] = {
"card_types": type_counts,
"avg_cmc": avg_cmc,
@@ -379,27 +410,31 @@ def build_deck_context(
"draw_count": len(roles["draw"]),
"archetype": archetype,
}
ctx["combo_pieces"] = combo_pieces[:15]
ctx["combo_pieces"] = combo_pieces[:15] # cap to save tokens
ctx["key_cards"] = list(set(key_cards))[:15]
return ctx
# ══════════════════════════════════════════════════════════════
# Action Schema
# Action Schema (Step 3)
# ══════════════════════════════════════════════════════════════
# Valid actions the LLM can return
VALID_ACTIONS = [
"play_land",
"cast_creature",
"cast_removal",
"cast_board_wipe",
"cast_ramp",
"cast_spell",
"attack_all",
"attack_safe",
"hold",
"play_land", # Play a land from hand
"cast_creature", # Cast a creature spell
"cast_removal", # Cast targeted removal
"cast_board_wipe", # Cast a board wipe
"cast_ramp", # Cast a ramp spell
"cast_spell", # Cast any other non-creature spell
"attack_all", # Attack with all eligible creatures
"attack_safe", # Attack only with evasive/large creatures
"hold", # Pass / don't act (save mana, bluff)
]
# Action schema for the LLM's JSON response
ACTION_SCHEMA = {
"action": "one of: " + ", ".join(VALID_ACTIONS),
"target_card": "(optional) name of card to cast or target",
@@ -408,7 +443,7 @@ ACTION_SCHEMA = {
# ══════════════════════════════════════════════════════════════
# System / User Prompts
# System / User Prompts (Step 3)
# ══════════════════════════════════════════════════════════════
SYSTEM_PROMPT = """You are an expert Magic: The Gathering Commander player.
@@ -498,19 +533,14 @@ class DeepSeekBrain:
"""
LLM-powered decision engine for the AI opponent.
Targets GPT-OSS 20B (Q4_K_M) via Ollama by default.
Sends game state to a local DeepSeek model and parses the action response.
Falls back to heuristic on timeout/error.
Decision log entries include game outcome once flush_log() is called
with a GameResult, enabling downstream RL weight learning.
"""
def __init__(self, config: DeepSeekConfig | None = None):
self.config = config or DeepSeekConfig()
self._cache: OrderedDict[str, dict] = OrderedDict()
self._decision_log: list[dict] = []
self._current_game_id: str = self._new_game_id()
self._ai_player_index: int | None = None
self._total_calls = 0
self._total_fallbacks = 0
self._total_cache_hits = 0
@@ -518,18 +548,6 @@ class DeepSeekBrain:
self._connected = False
self._log_file = None
@staticmethod
def _new_game_id() -> str:
return hashlib.md5(str(time.time()).encode()).hexdigest()[:12]
def new_game(self, ai_player_index: int | None = None) -> None:
"""
Signal the start of a new game.
Resets game_id and records ai_player_index for reward calculation.
"""
self._current_game_id = self._new_game_id()
self._ai_player_index = ai_player_index
def check_connection(self) -> bool:
"""Test if the LLM endpoint is reachable."""
try:
@@ -541,44 +559,47 @@ class DeepSeekBrain:
self._connected = True
models = data.get("data", [])
if models:
# Auto-detect model name from LM Studio
model_id = models[0].get("id", self.config.model)
if model_id:
self.config.model = model_id
logger.info("Auto-detected model: %s", model_id)
logger.info("LLM brain connected to %s", self.config.api_base)
logger.info("DeepSeek brain connected to %s", self.config.api_base)
return True
except Exception as e:
logger.warning("LLM brain connection failed: %s", e)
logger.warning("DeepSeek brain connection failed: %s", e)
self._connected = False
return False
def choose_action(
self,
sim_state,
player_index: int,
sim_state, # SimState
player_index: int, # Which player is the AI
turn: int,
available_mana: int | None = None,
deck_context: dict | None = None,
deck_context: dict | None = None, # Full deck intelligence from build_deck_context()
) -> dict:
"""
Choose an action for the AI player.
Returns:
{
"action": str,
"target_card": str,
"reasoning": str,
"source": "llm" | "heuristic" | "cache",
"action": str, # One of VALID_ACTIONS
"target_card": str, # Optional card name
"reasoning": str, # Brief explanation
"source": "deepseek" | "heuristic" | "cache",
"latency_ms": float,
}
"""
self._total_calls += 1
t_start = time.time()
# Build game state snapshot with deck intelligence
snapshot = build_game_state_snapshot(sim_state, player_index, turn, deck_context=deck_context)
if available_mana is not None:
snapshot["my_mana_available"] = available_mana
# Check cache
if self.config.cache_enabled:
cache_key = self._cache_key(snapshot)
if cache_key in self._cache:
@@ -588,34 +609,39 @@ class DeepSeekBrain:
cached["latency_ms"] = round((time.time() - t_start) * 1000, 1)
return cached
# Try LLM
if not self._connected:
return self._fallback_action(snapshot, t_start, "not_connected")
try:
result = self._call_llm(snapshot)
result["source"] = "llm"
result["source"] = "deepseek"
result["latency_ms"] = round((time.time() - t_start) * 1000, 1)
# Cache the result
if self.config.cache_enabled:
self._put_cache(cache_key, result)
# Log decision
if self.config.log_decisions:
self._log_decision(snapshot, result, player_index)
self._log_decision(snapshot, result)
self._total_latency_ms += result["latency_ms"]
return result
except Exception as e:
logger.warning("LLM call failed (turn %d): %s", turn + 1, e)
logger.warning("DeepSeek call failed (turn %d): %s", turn + 1, e)
if self.config.fallback_on_timeout:
return self._fallback_action(snapshot, t_start, str(e))
raise
def _call_llm(self, snapshot: dict) -> dict:
"""Send game state to LLM and parse response."""
# Build the prompt
game_state_json = json.dumps(snapshot, indent=2)
user_prompt = USER_PROMPT_TEMPLATE.format(game_state_json=game_state_json)
# Build OpenAI-compatible request
payload = {
"model": self.config.model,
"messages": [
@@ -636,6 +662,7 @@ class DeepSeekBrain:
with urlopen(req, timeout=self.config.request_timeout) as resp:
resp_data = json.loads(resp.read())
# Extract the response text
choices = resp_data.get("choices", [])
if not choices:
raise ValueError("Empty response from LLM")
@@ -644,16 +671,23 @@ class DeepSeekBrain:
return self._parse_action(raw_text)
def _parse_action(self, raw_text: str) -> dict:
"""Parse the LLM's response into a validated action dict."""
"""
Parse the LLM's response into a validated action dict.
Handles common LLM quirks (markdown fences, extra text, etc.)
"""
text = raw_text.strip()
# Strip <think> blocks (DeepSeek-R1 / reasoning models)
# Strip <think>...</think> tags (DeepSeek-R1 reasoning)
# Handle both closed tags and unclosed tags (truncated by max_tokens)
text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
text = re.sub(r'<think>.*', '', text, flags=re.DOTALL).strip()
# Strip markdown fences
# Strip markdown code fences
text = re.sub(r'^```(?:json)?\s*', '', text)
text = re.sub(r'\s*```$', '', text)
text = text.strip()
# Try to find a JSON object in the text
json_match = re.search(r'\{[^{}]*\}', text, re.DOTALL)
if json_match:
text = json_match.group(0)
@@ -661,6 +695,7 @@ class DeepSeekBrain:
try:
parsed = json.loads(text)
except json.JSONDecodeError:
# Last resort: try to extract action keyword
for action in VALID_ACTIONS:
if action in raw_text.lower():
return {
@@ -670,14 +705,16 @@ class DeepSeekBrain:
}
raise ValueError(f"Could not parse LLM response as JSON: {raw_text[:200]}")
# Validate action
action = parsed.get("action", "hold")
if action not in VALID_ACTIONS:
# Fuzzy match
for valid in VALID_ACTIONS:
if valid in action.lower() or action.lower() in valid:
action = valid
break
else:
action = "hold"
action = "hold" # safe default
return {
"action": action,
@@ -695,15 +732,18 @@ class DeepSeekBrain:
opp_board = snapshot.get("opp_board", [])
turn = snapshot.get("turn", 1)
# Priority: land > ramp (early) > removal (if outnumbered) > creature > attack > hold
action = "hold"
target = ""
reasoning = f"Fallback ({reason})"
# 1. Play a land if available
lands_in_hand = [c for c in hand if c.get("type") == "land"]
if lands_in_hand:
action = "play_land"
target = lands_in_hand[0]["name"]
reasoning = "Play land first"
# 2. Early game: prioritize ramp
elif turn <= 4:
ramp_cards = [c for c in hand if c.get("role") == "ramp" and c.get("cmc", 99) <= mana]
if ramp_cards:
@@ -712,31 +752,38 @@ class DeepSeekBrain:
target = ramp_cards[0]["name"]
reasoning = "Early ramp"
else:
# Play cheapest creature
creatures = [c for c in hand if c.get("type") == "creature" and c.get("cmc", 99) <= mana]
if creatures:
creatures.sort(key=lambda c: c.get("cmc", 0))
action = "cast_creature"
target = creatures[0]["name"]
reasoning = "Cheap creature development"
# 3. If opponent has many creatures and we have board wipe
elif len(opp_board) >= 3 and len(my_board) <= 1:
wipes = [c for c in hand if c.get("role") == "board_wipe" and c.get("cmc", 99) <= mana]
if wipes:
action = "cast_board_wipe"
target = wipes[0]["name"]
reasoning = "Opponent has board advantage, wiping"
# 4. If opponent has a big threat, use removal
elif opp_board:
removal = [c for c in hand if c.get("role") == "removal" and c.get("cmc", 99) <= mana]
if removal:
action = "cast_removal"
target = removal[0]["name"]
reasoning = "Remove opponent threat"
# 5. Play best creature
if action == "hold":
castable = [c for c in hand if c.get("type") == "creature" and c.get("cmc", 99) <= mana]
if castable:
# Pick the one with highest power
castable.sort(key=lambda c: -int(c.get("pt", "0/0").split("/")[0]) if c.get("pt") else 0)
action = "cast_creature"
target = castable[0]["name"]
reasoning = "Play threat"
# 6. Attack if we have creatures on board
if action == "hold" and my_board:
creatures_on_board = [c for c in my_board if c.get("type") == "creature" and not c.get("tapped")]
if creatures_on_board:
@@ -759,10 +806,13 @@ class DeepSeekBrain:
# ── Cache helpers ──
def _cache_key(self, snapshot: dict) -> str:
"""Create a deterministic cache key from game state."""
# Normalize — exclude things that don't affect decisions
key_data = json.dumps(snapshot, sort_keys=True)
return hashlib.md5(key_data.encode()).hexdigest()
def _put_cache(self, key: str, value: dict):
"""LRU cache insert."""
if key in self._cache:
self._cache.move_to_end(key)
self._cache[key] = value
@@ -771,11 +821,10 @@ class DeepSeekBrain:
# ── Decision logging ──
def _log_decision(self, snapshot: dict, result: dict, player_index: int | None = None) -> None:
def _log_decision(self, snapshot: dict, result: dict):
"""Log decision for future training data."""
entry = {
"timestamp": time.time(),
"game_id": self._current_game_id,
"player_index": player_index if player_index is not None else self._ai_player_index,
"game_state": snapshot,
"decision": {
"action": result.get("action"),
@@ -784,67 +833,23 @@ class DeepSeekBrain:
"source": result.get("source"),
"latency_ms": result.get("latency_ms"),
},
"game_result": None,
}
self._decision_log.append(entry)
def flush_log(
self,
filepath: str | None = None,
game_result=None,
) -> str | None:
"""
Write accumulated decision log to a JSONL file.
Pass game_result (GameResult from engine.py) to backfill every entry with:
winner_seat, winner_name, turns, player_lives, reward
"""
def flush_log(self, filepath: str | None = None):
"""Write accumulated decision log to JSONL file."""
if not self._decision_log:
return None
result_payload: dict | None = None
if game_result is not None:
try:
winner_seat: int = game_result.winner_seat
winner_name: str = game_result.players[winner_seat].name
turns: int = game_result.turns
player_lives: dict[int, int] = {
pr.seat_index: pr.life
for pr in game_result.players
}
result_payload = {
"winner_seat": winner_seat,
"winner_name": winner_name,
"turns": turns,
"player_lives": player_lives,
}
except Exception as exc:
logger.warning("Could not extract game_result fields: %s", exc)
for entry in self._decision_log:
if result_payload is None:
entry["game_result"] = None
continue
pi = entry.get("player_index")
winner_seat = result_payload["winner_seat"]
player_lives = result_payload["player_lives"]
if pi is not None and len(player_lives) >= 2:
my_life = player_lives.get(pi, 0)
opp_life_sum = sum(v for k, v in player_lives.items() if k != pi)
opp_count = max(len(player_lives) - 1, 1)
avg_opp_life = opp_life_sum / opp_count
raw_reward = (my_life - avg_opp_life) / 40.0
reward = round(max(-1.0, min(1.0, raw_reward)), 4)
else:
reward = 1.0 if (pi is not None and pi == winner_seat) else -1.0
entry["game_result"] = {**result_payload, "reward": reward}
return
if filepath is None:
log_dir = self.config.log_dir or os.path.join(
os.path.dirname(__file__), "..", "..", "..", "logs", "decisions"
)
os.makedirs(log_dir, exist_ok=True)
filepath = os.path.join(log_dir, f"decisions_{int(time.time())}.jsonl")
filepath = os.path.join(
log_dir,
f"decisions_{int(time.time())}.jsonl"
)
with open(filepath, "a", encoding="utf-8") as f:
for entry in self._decision_log:
@@ -858,9 +863,7 @@ class DeepSeekBrain:
def get_stats(self) -> dict:
"""Return performance statistics."""
avg_latency = (
self._total_latency_ms / max(
self._total_calls - self._total_fallbacks - self._total_cache_hits, 1
)
self._total_latency_ms / max(self._total_calls - self._total_fallbacks - self._total_cache_hits, 1)
)
return {
"total_calls": self._total_calls,
@@ -872,5 +875,4 @@ class DeepSeekBrain:
"model": self.config.model,
"api_base": self.config.api_base,
"pending_log_entries": len(self._decision_log),
"current_game_id": self._current_game_id,
}
+109 -193
View File
@@ -5,16 +5,11 @@ Extends the headless GameEngine so that one player (the AI opponent)
uses the DeepSeek LLM brain for decision-making instead of the static
heuristic scorer.
N-player ready: accepts 2–4 decks. The AI-controlled seat uses DeepSeek;
all other seats use the heuristic.
The LLM decides:
1. Whether to play a land (and which one)
2. Which spell(s) to cast (up to 2 per turn, same as base engine)
3. Combat stance: attack_all, attack_safe, or hold
Multiplayer attack targeting uses the weakest-opponent heuristic.
If the LLM is unreachable or times out, falls back to the base-engine
heuristic transparently.
@@ -36,7 +31,7 @@ import random
import uuid
from typing import Optional
from commander_ai_lab.sim.models import Card, GameResult, Player, PlayerResult, PlayerStats, SimState
from commander_ai_lab.sim.models import Card, GameResult, Player, PlayerStats, SimState
from commander_ai_lab.sim.rules import AI_DEFAULT_WEIGHTS, enrich_card, score_card
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig, build_deck_context
@@ -82,40 +77,14 @@ class DeepSeekGameEngine:
color_identity: list[str] | None = None,
win_rate: float | None = None,
) -> GameResult:
"""Backward-compatible 2-player entry point."""
return self.run_n(
decks=[deck_a, deck_b],
names=[name_a, name_b],
game_id=game_id,
archetype=archetype,
commander_name=commander_name,
color_identity=color_identity,
win_rate=win_rate,
)
def run_n(
self,
decks: list[list[Card]],
names: list[str] | None = None,
game_id: str | None = None,
archetype: str = "midrange",
commander_name: str = "",
color_identity: list[str] | None = None,
win_rate: float | None = None,
) -> GameResult:
"""Run a single N-player game. The AI player uses DeepSeek for decisions."""
n = len(decks)
if names is None:
names = [f"Player {chr(65 + i)}" for i in range(n)]
sim = self._create_state(decks, names)
"""Run a single game. The AI player uses DeepSeek for decisions."""
sim = self._create_state(deck_a, deck_b, name_a, name_b)
game_log: list[dict] = []
elimination_order: list[int] = []
gid = game_id or str(uuid.uuid4())[:12]
turn_decisions: list[dict] = []
turn_decisions: list[dict] = [] # raw (snapshot, action_type) pairs
# Build deck intelligence context once at game start
ai_idx = min(self.ai_player_index, n - 1)
ai_deck = decks[ai_idx]
ai_deck = deck_b if self.ai_player_index == 1 else deck_a
self._deck_context = build_deck_context(
full_deck=ai_deck,
commander_name=commander_name,
@@ -128,7 +97,7 @@ class DeepSeekGameEngine:
for turn in range(sim.max_turns):
turn_entry = {"turn": turn + 1, "phases": []}
for pi in range(len(sim.players)):
for pi in range(2):
p = sim.players[pi]
if p.eliminated:
continue
@@ -149,7 +118,7 @@ class DeepSeekGameEngine:
pre_snapshot = self._capture_ml_snapshot(sim, pi, turn)
# ── Decide: LLM or Heuristic? ──
action_type = "pass"
action_type = "pass" # default
if pi == self.ai_player_index and self.brain._connected:
action_type = self._play_turn_deepseek(sim, pi, turn, phase_events, getattr(self, '_deck_context', None))
else:
@@ -163,38 +132,27 @@ class DeepSeekGameEngine:
turn_decisions.append(pre_snapshot)
# ── Track board size ──
board_size = len(sim.get_battlefield(pi))
board_size = sum(1 for c in sim.battlefield if c.owner_id == pi)
if board_size > p.stats.max_board_size:
p.stats.max_board_size = board_size
# ── Untap ──
for c in sim.get_battlefield(pi):
c.tapped = False
for c in sim.battlefield:
if c.owner_id == pi:
c.tapped = False
if self.record_log:
life_dict = {sp.name: sp.life for sp in sim.players}
boards_dict = {
f"board_{si}": [c.name for c in sim.get_battlefield(si) if c.is_creature()]
for si in range(len(sim.players))
}
phase_entry: dict = {
turn_entry["phases"].append({
"player": p.name,
"playerId": pi,
"events": phase_events,
"lifeAfter": life_dict,
**boards_dict,
}
if len(sim.players) == 2:
phase_entry["boardA"] = boards_dict.get("board_0", [])
phase_entry["boardB"] = boards_dict.get("board_1", [])
turn_entry["phases"].append(phase_entry)
# ── Check for new eliminations ──
for si, sp in enumerate(sim.players):
if sp.eliminated and si not in elimination_order:
elimination_order.append(si)
if self.record_log:
phase_events.append(f"{sp.name} eliminated!")
"lifeAfter": {
sim.players[0].name: sim.players[0].life,
sim.players[1].name: sim.players[1].life,
},
"boardA": [c.name for c in sim.battlefield if c.owner_id == 0 and c.is_creature()],
"boardB": [c.name for c in sim.battlefield if c.owner_id == 1 and c.is_creature()],
})
# Check game over
alive = [p for p in sim.players if not p.eliminated]
@@ -202,16 +160,21 @@ class DeepSeekGameEngine:
if self.record_log:
game_log.append(turn_entry)
if len(alive) <= 1:
if self.record_log:
elim_name = next((p.name for p in sim.players if p.eliminated), None)
if elim_name:
game_log[-1]["event"] = f"{elim_name} eliminated!"
break
if self.record_log:
game_log.append(turn_entry) if turn_entry not in game_log else None
result = self._build_result(sim, final_turn, elimination_order)
result = self._build_result(sim, final_turn, name_a, name_b)
if self.record_log:
result.game_log = game_log
# Stamp game outcome on all ML decisions and add to engine collection
if self.ml_log and turn_decisions:
ai_won = result.winner_seat == self.ai_player_index
outcome = "win" if ai_won else "loss"
outcome = "win" if result.winner == 0 else "loss" if result.winner == 1 else "draw"
for td in turn_decisions:
td["game_outcome"] = outcome
self.ml_decisions.extend(turn_decisions)
@@ -254,7 +217,7 @@ class DeepSeekGameEngine:
if self.record_log:
events.append(f"[AI Brain: {source}] Action: {action} "
f"{'\u2192 ' + target_card_name if target_card_name else ''} "
f"{'→ ' + target_card_name if target_card_name else ''} "
f"({reasoning})")
# ── Phase 1: Land drop ──
@@ -280,7 +243,7 @@ class DeepSeekGameEngine:
elif action == "hold":
# LLM chose to hold — no combat
if self.record_log:
events.append("Holds \u2014 no attack")
events.append("Holds — no attack")
else:
# Default: still attack if we have creatures and it makes sense
self._resolve_combat_heuristic(sim, pi, turn, events)
@@ -352,7 +315,7 @@ class DeepSeekGameEngine:
land_card.tapped = False
land_card.id = sim.next_card_id
sim.next_card_id += 1
sim.add_to_battlefield(pi, land_card)
sim.battlefield.append(land_card)
p.stats.lands_played += 1
if events is not None and self.record_log:
@@ -463,33 +426,30 @@ class DeepSeekGameEngine:
# Pay mana
mana_needed = card.cmc or 0
for bf_card in sim.get_battlefield(pi):
for bf_card in sim.battlefield:
if mana_needed <= 0:
break
if not bf_card.tapped and bf_card.is_land():
if bf_card.owner_id == pi and not bf_card.tapped and bf_card.is_land():
bf_card.tapped = True
mana_needed -= 1
p.stats.mana_spent += card.cmc or 0
p.stats.spells_cast += 1
# Handle removal — target the scariest creature across all opponents
# Handle removal
if card.is_removal:
p.stats.removal_used += 1
all_opp_creatures = []
for oi in range(len(sim.players)):
if oi == pi or sim.players[oi].eliminated:
continue
for c in sim.get_battlefield(oi):
if c.is_creature():
all_opp_creatures.append(c)
if all_opp_creatures:
all_opp_creatures.sort(key=lambda c: -score_card(c, w))
killed = all_opp_creatures[0]
sim.remove_from_battlefield(killed.id)
opp_creatures = [
c for c in sim.battlefield
if c.owner_id != pi and c.is_creature()
]
if opp_creatures:
opp_creatures.sort(key=lambda c: -score_card(c, w))
killed = opp_creatures[0]
sim.battlefield = [c for c in sim.battlefield if c.id != killed.id]
sim.players[killed.owner_id].graveyard.append(killed)
if events is not None:
events.append(f"Cast {card.name} (removal) \u2014 destroyed {killed.name}")
events.append(f"Cast {card.name} (removal) — destroyed {killed.name}")
else:
if events is not None:
events.append(f"Cast {card.name} (removal, no targets)")
@@ -497,23 +457,20 @@ class DeepSeekGameEngine:
elif card.is_board_wipe:
p.stats.board_wipes_used += 1
wiped_names = []
for seat_idx in range(len(sim.players)):
bf = sim.get_battlefield(seat_idx)
keep = []
for c in bf:
if c.is_creature():
wiped_names.append(c.name)
sim.players[c.owner_id].graveyard.append(c)
else:
keep.append(c)
sim.battlefields[seat_idx] = keep
wiped = [c.name for c in sim.battlefield if c.is_creature()]
surviving = []
for c in sim.battlefield:
if c.is_creature():
sim.players[c.owner_id].graveyard.append(c)
else:
surviving.append(c)
sim.battlefield = surviving
p.graveyard.append(card)
if events is not None:
events.append(f"Cast {card.name} (board wipe) \u2014 destroyed {len(wiped_names)} creatures")
events.append(f"Cast {card.name} (board wipe) — destroyed {len(wiped)} creatures")
else:
sim.add_to_battlefield(pi, card)
sim.battlefield.append(card)
if card.is_creature():
p.stats.creatures_played += 1
if events is not None:
@@ -528,23 +485,6 @@ class DeepSeekGameEngine:
return self._count_untapped_lands(sim, pi)
# ──────────────────────────────────────────────────────────
# Attack target selection (N-player)
# ──────────────────────────────────────────────────────────
@staticmethod
def _select_attack_target(sim: SimState, pi: int) -> int:
"""Select the weakest non-eliminated opponent (lowest life). Returns seat or -1."""
best_seat = -1
best_life = float('inf')
for si, sp in enumerate(sim.players):
if si == pi or sp.eliminated:
continue
if sp.life < best_life:
best_life = sp.life
best_seat = si
return best_seat
# ──────────────────────────────────────────────────────────
# Combat — DeepSeek directed
# ──────────────────────────────────────────────────────────
@@ -559,22 +499,23 @@ class DeepSeekGameEngine:
attack_safe: only evasive/large creatures
"""
p = sim.players[pi]
opp_idx = self._select_attack_target(sim, pi)
if opp_idx == -1:
return
opp_idx = 1 - pi
opp = sim.players[opp_idx]
if opp.eliminated:
return
my_creatures = [
c for c in sim.get_battlefield(pi)
if c.is_creature() and not c.tapped
c for c in sim.battlefield
if c.owner_id == pi and c.is_creature() and not c.tapped
and (turn > 0 or c.turn_played != turn)
]
if not my_creatures:
return
opp_blockers = [
c for c in sim.get_battlefield(opp_idx)
if c.is_creature() and not c.tapped
c for c in sim.battlefield
if c.owner_id == opp_idx and c.is_creature() and not c.tapped
]
# Select attackers based on LLM's preference
@@ -582,6 +523,7 @@ class DeepSeekGameEngine:
if attack_mode == "attack_all":
attackers = my_creatures[:]
elif attack_mode == "attack_safe":
# Only evasive (flying, trample, menace) or large (power >= 4)
for atk in my_creatures:
if (atk.has_keyword("flying") or atk.has_keyword("trample")
or atk.has_keyword("menace") or atk.get_power() >= 4):
@@ -593,7 +535,7 @@ class DeepSeekGameEngine:
if events is not None and attackers:
mode_label = "all-in" if attack_mode == "attack_all" else "safe"
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
events.append(f"Attacks {opp.name} ({mode_label}): {', '.join(atk_names)}")
events.append(f"Attacks ({mode_label}): {', '.join(atk_names)}")
# Resolve blocking and damage (same logic as base engine)
self._resolve_damage(sim, pi, attackers, opp_blockers, turn, events)
@@ -601,24 +543,25 @@ class DeepSeekGameEngine:
def _resolve_combat_heuristic(
self, sim: SimState, pi: int, turn: int, events: list[str] | None
) -> bool:
"""Heuristic combat (N-player ready). Returns True if attacked."""
"""Heuristic combat (same as base GameEngine._resolve_combat). Returns True if attacked."""
p = sim.players[pi]
opp_idx = self._select_attack_target(sim, pi)
if opp_idx == -1:
return False
opp_idx = 1 - pi
opp = sim.players[opp_idx]
if opp.eliminated:
return False
my_creatures = [
c for c in sim.get_battlefield(pi)
if c.is_creature() and not c.tapped
c for c in sim.battlefield
if c.owner_id == pi and c.is_creature() and not c.tapped
and (turn > 0 or c.turn_played != turn)
]
if not my_creatures:
return False
opp_blockers = [
c for c in sim.get_battlefield(opp_idx)
if c.is_creature() and not c.tapped
c for c in sim.battlefield
if c.owner_id == opp_idx and c.is_creature() and not c.tapped
]
# Same attacker selection as base engine
@@ -650,7 +593,7 @@ class DeepSeekGameEngine:
if events is not None and attackers:
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
events.append(f"Attacks with: {', '.join(atk_names)}")
self._resolve_damage(sim, pi, attackers, opp_blockers, turn, events)
return len(attackers) > 0
@@ -666,9 +609,7 @@ class DeepSeekGameEngine:
):
"""Resolve blocking and combat damage."""
p = sim.players[pi]
opp_idx = self._select_attack_target(sim, pi)
if opp_idx == -1:
return
opp_idx = 1 - pi
opp = sim.players[opp_idx]
w = self.weights
@@ -705,13 +646,13 @@ class DeepSeekGameEngine:
combat_details.append(f"{atk.name} blocked by {blocker.name}")
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
sim.remove_from_battlefield(blocker.id)
sim.battlefield = [c for c in sim.battlefield if c.id != blocker.id]
sim.players[opp_idx].graveyard.append(blocker)
if events is not None:
combat_details.append(f" {blocker.name} dies")
if b_pow >= a_tou or blocker.has_keyword("deathtouch"):
sim.remove_from_battlefield(atk.id)
sim.battlefield = [c for c in sim.battlefield if c.id != atk.id]
sim.players[pi].graveyard.append(atk)
if events is not None:
combat_details.append(f" {atk.name} dies")
@@ -749,36 +690,35 @@ class DeepSeekGameEngine:
def _capture_ml_snapshot(self, sim: SimState, pi: int, turn: int) -> dict:
"""
Capture the current game state in the format expected by the ML
training pipeline (dataset_builder \u2192 state_encoder \u2192 labeler).
training pipeline (dataset_builder → state_encoder → labeler).
Returns a dict compatible with ml-decisions-*.jsonl format.
"""
players_data = []
for seat in range(len(sim.players)):
for seat in range(2):
p = sim.players[seat]
# Count creatures and lands on battlefield
seat_bf = sim.get_battlefield(seat)
my_creatures = sum(
1 for c in seat_bf
if c.is_creature()
1 for c in sim.battlefield
if c.owner_id == seat and c.is_creature()
)
my_lands = sum(
1 for c in seat_bf
if c.is_land()
1 for c in sim.battlefield
if c.owner_id == seat and c.is_land()
)
available_mana = sum(
1 for c in seat_bf
if not c.tapped and c.is_land()
1 for c in sim.battlefield
if c.owner_id == seat and not c.tapped and c.is_land()
)
# Card name lists for zones (for embedding lookup)
hand_names = [c.name for c in p.hand]
graveyard_names = [c.name for c in p.graveyard]
battlefield_names = [
c.name for c in seat_bf
if not c.is_land()
c.name for c in sim.battlefield
if c.owner_id == seat and not c.is_land()
]
# Command zone \u2014 not explicitly tracked in Python sim, use empty
# Command zone — not explicitly tracked in Python sim, use empty
command_zone_names = []
players_data.append({
@@ -795,7 +735,7 @@ class DeepSeekGameEngine:
"command_zone": command_zone_names,
})
# Determine game phase \u2014 Python sim is simplified, treat as main_1
# Determine game phase — Python sim is simplified, treat as main_1
phase = "main_1"
return {
@@ -839,17 +779,16 @@ class DeepSeekGameEngine:
def _count_untapped_lands(self, sim: SimState, pi: int) -> int:
return sum(
1 for c in sim.get_battlefield(pi)
if not c.tapped and c.is_land()
1 for c in sim.battlefield
if c.owner_id == pi and not c.tapped and c.is_land()
)
def _create_state(
self, decks: list[list[Card]],
names: list[str],
self, deck_a: list[Card], deck_b: list[Card],
name_a: str, name_b: str,
) -> SimState:
"""Create initial simulation state with shuffled decks and opening hands (N players)."""
sim = SimState(max_turns=self.max_turns)
for idx, (deck, name) in enumerate(zip(decks, names)):
for idx, (deck, name) in enumerate([(deck_a, name_a), (deck_b, name_b)]):
cards = [enrich_card(c.clone()) for c in deck]
random.shuffle(cards)
hand = cards[:7]
@@ -863,54 +802,31 @@ class DeepSeekGameEngine:
stats=PlayerStats(cards_drawn=7),
)
sim.players.append(player)
sim.init_battlefields(len(sim.players))
return sim
def _build_result(
self, sim: SimState, final_turn: int,
elimination_order: list[int] | None = None,
name_a: str, name_b: str,
) -> GameResult:
"""Determine winner and build result object (N-player)."""
if elimination_order is None:
elimination_order = []
n = len(sim.players)
pa = sim.players[0]
pb = sim.players[1]
alive = [i for i, p in enumerate(sim.players) if not p.eliminated]
if len(alive) == 1:
winner = alive[0]
elif len(alive) == 0:
winner = max(range(n), key=lambda i: sim.players[i].life)
if pa.eliminated and not pb.eliminated:
winner = 1
elif not pa.eliminated and pb.eliminated:
winner = 0
else:
winner = max(alive, key=lambda i: sim.players[i].life)
finish: dict[int, int] = {winner: 1}
position = 2
for seat in reversed(elimination_order):
if seat != winner:
finish[seat] = position
position += 1
remaining_alive = sorted(
[i for i in alive if i != winner],
key=lambda i: -sim.players[i].life,
)
for seat in remaining_alive:
if seat not in finish:
finish[seat] = position
position += 1
player_results = []
for seat, p in enumerate(sim.players):
player_results.append(PlayerResult(
seat_index=seat,
name=p.name,
life=p.life,
eliminated=p.eliminated,
finish_position=finish.get(seat, n),
stats=p.stats,
))
winner = 0 if pa.life >= pb.life else 1
return GameResult(
winner_seat=winner,
winner=winner,
turns=min(final_turn, sim.max_turns),
players=player_results,
player_a_name=name_a,
player_a_life=pa.life,
player_a_eliminated=pa.eliminated,
player_a_stats=pa.stats,
player_b_name=name_b,
player_b_life=pb.life,
player_b_eliminated=pb.eliminated,
player_b_stats=pb.stats,
)
+154 -227
View File
@@ -4,19 +4,12 @@ Commander AI Lab — Headless Game Engine
A headless Commander game simulator for Monte Carlo analysis.
Faithful port of dtSimGame() / dtCreatePlayer() from mtg-commander-lan.
N-player ready: accepts 2–4 decks and runs a full multiplayer game.
Simulates simplified Commander games:
Simulates simplified 1v1 Commander games:
- Draw, land drop, spell casting (up to 2 per turn)
- AI card scoring for play priority
- Simplified combat with flying, trample, deathtouch, lifelink, menace, reach
- Multiplayer attack targeting: weakest non-eliminated opponent heuristic
- Creature removal and board wipe handling
- Win by elimination (life <= 0) or life comparison at max turns
- Elimination order tracking for N-player finish positions
Weights are loaded automatically from learned_weights.json (if present)
via load_weights(), so simulation-trained weights take effect without any
code changes. Pass explicit weights= to override.
"""
from __future__ import annotations
@@ -28,32 +21,24 @@ from commander_ai_lab.sim.models import (
Card,
GameResult,
Player,
PlayerResult,
PlayerStats,
SimState,
)
from commander_ai_lab.sim.rules import (
AI_DEFAULT_WEIGHTS,
enrich_card,
load_weights,
score_card,
)
class GameEngine:
"""
Headless Commander game engine (N-player ready).
Headless Commander game engine.
Usage::
engine = GameEngine(max_turns=25)
# 2-player (backward-compatible)
result = engine.run(deck_a, deck_b, name_a="Deck A", name_b="Deck B")
# 4-player
result = engine.run_n(decks=[d1, d2, d3, d4], names=["A","B","C","D"])
Weights are loaded from learned_weights.json automatically. Pass
``weights`` explicitly to override (e.g. for A/B testing).
"""
def __init__(
@@ -65,8 +50,7 @@ class GameEngine:
):
self.max_turns = max_turns
self.starting_life = starting_life
# Auto-load learned weights; caller can override by passing weights=
self.weights = weights if weights is not None else load_weights()
self.weights = weights or AI_DEFAULT_WEIGHTS
self.record_log = record_log
# ──────────────────────────────────────────────────────────
@@ -80,43 +64,24 @@ class GameEngine:
name_a: str = "Player A",
name_b: str = "Player B",
) -> GameResult:
"""Backward-compatible 2-player entry point."""
return self.run_n(
decks=[deck_a, deck_b],
names=[name_a, name_b],
)
def run_n(
self,
decks: list[list[Card]],
names: list[str] | None = None,
) -> GameResult:
"""Run a single headless N-player game and return the result."""
n = len(decks)
if names is None:
names = [f"Player {chr(65 + i)}" for i in range(n)]
sim = self._create_state(decks, names)
game_log: list[dict] = []
elimination_order: list[int] = [] # seats in order of elimination
"""Run a single headless game and return the result."""
sim = self._create_state(deck_a, deck_b, name_a, name_b)
game_log: list[dict] = [] # turn-by-turn log
final_turn = 0
for turn in range(sim.max_turns):
turn_entry = {"turn": turn + 1, "phases": []}
for pi in range(len(sim.players)):
for pi in range(2):
p = sim.players[pi]
if p.eliminated:
continue
p.stats.turns_alive += 1
phase_events: list[str] = []
# ── Untap (correct MTG order: untap is first) ──
for c in sim.get_battlefield(pi):
c.tapped = False
# ── Draw ──
if p.library:
drawn = p.library[-1]
drawn = p.library[-1] # peek before pop
p.hand.append(p.library.pop())
p.stats.cards_drawn += 1
if self.record_log:
@@ -126,9 +91,10 @@ class GameEngine:
land_before = p.stats.lands_played
self._play_land(sim, pi)
if self.record_log and p.stats.lands_played > land_before:
# Find what land was just played
last_land = next(
(c for c in reversed(sim.get_battlefield(pi))
if c.is_land()),
(c for c in reversed(sim.battlefield)
if c.owner_id == pi and c.is_land()),
None,
)
if last_land:
@@ -138,40 +104,36 @@ class GameEngine:
available_mana = self._count_untapped_lands(sim, pi)
# ── Play spells (up to 2) ──
spells_before = p.stats.spells_cast
self._play_spells(sim, pi, available_mana, phase_events if self.record_log else None)
# ── Track board size ──
board_size = len(sim.get_battlefield(pi))
board_size = sum(
1 for c in sim.battlefield if c.owner_id == pi
)
if board_size > p.stats.max_board_size:
p.stats.max_board_size = board_size
# ── Combat ──
combat_before_life_a = sim.players[0].life
combat_before_life_b = sim.players[1].life
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
# ── Untap ──
for c in sim.battlefield:
if c.owner_id == pi:
c.tapped = False
if self.record_log:
life_dict = {sp.name: sp.life for sp in sim.players}
boards_dict = {
f"board_{si}": [c.name for c in sim.get_battlefield(si) if c.is_creature()]
for si in range(len(sim.players))
}
phase_entry: dict = {
turn_entry["phases"].append({
"player": p.name,
"playerId": pi,
"events": phase_events,
"lifeAfter": life_dict,
**boards_dict,
}
if len(sim.players) == 2:
phase_entry["boardA"] = boards_dict.get("board_0", [])
phase_entry["boardB"] = boards_dict.get("board_1", [])
turn_entry["phases"].append(phase_entry)
# ── Check for new eliminations after this player's turn ──
for si, sp in enumerate(sim.players):
if sp.eliminated and si not in elimination_order:
elimination_order.append(si)
if self.record_log:
phase_events.append(f"{sp.name} eliminated!")
"lifeAfter": {sim.players[0].name: sim.players[0].life,
sim.players[1].name: sim.players[1].life},
"boardA": [c.name for c in sim.battlefield if c.owner_id == 0 and c.is_creature()],
"boardB": [c.name for c in sim.battlefield if c.owner_id == 1 and c.is_creature()],
})
# Check game over
alive = [p for p in sim.players if not p.eliminated]
@@ -179,9 +141,15 @@ class GameEngine:
if self.record_log:
game_log.append(turn_entry)
if len(alive) <= 1:
if self.record_log:
elim_name = next((p.name for p in sim.players if p.eliminated), None)
if elim_name:
game_log[-1]["event"] = f"{elim_name} eliminated!"
break
if self.record_log:
game_log.append(turn_entry) if turn_entry not in game_log else None
result = self._build_result(sim, final_turn, elimination_order)
result = self._build_result(sim, final_turn, name_a, name_b)
if self.record_log:
result.game_log = game_log
return result
@@ -192,13 +160,16 @@ class GameEngine:
def _create_state(
self,
decks: list[list[Card]],
names: list[str],
deck_a: list[Card],
deck_b: list[Card],
name_a: str,
name_b: str,
) -> SimState:
"""Create initial simulation state with shuffled decks and opening hands (N players)."""
"""Create initial simulation state with shuffled decks and opening hands."""
sim = SimState(max_turns=self.max_turns)
for idx, (deck, name) in enumerate(zip(decks, names)):
for idx, (deck, name) in enumerate([(deck_a, name_a), (deck_b, name_b)]):
# Enrich + deep copy cards
cards = [enrich_card(c.clone()) for c in deck]
random.shuffle(cards)
@@ -215,7 +186,6 @@ class GameEngine:
)
sim.players.append(player)
sim.init_battlefields(len(sim.players))
return sim
def _play_land(self, sim: SimState, pi: int) -> None:
@@ -234,15 +204,15 @@ class GameEngine:
land_card.tapped = False
land_card.id = sim.next_card_id
sim.next_card_id += 1
sim.add_to_battlefield(pi, land_card)
sim.battlefield.append(land_card)
p.stats.lands_played += 1
def _count_untapped_lands(self, sim: SimState, pi: int) -> int:
"""Count untapped lands for a player."""
return sum(
1
for c in sim.get_battlefield(pi)
if not c.tapped and c.is_land()
for c in sim.battlefield
if c.owner_id == pi and not c.tapped and c.is_land()
)
def _play_spells(self, sim: SimState, pi: int, available_mana: int, events: list | None = None) -> None:
@@ -250,18 +220,19 @@ class GameEngine:
p = sim.players[pi]
w = self.weights
# Score playable non-land cards
playable = []
for i, card in enumerate(p.hand):
if card.is_land():
continue
cmc = card.cmc or 0
if cmc <= available_mana:
playable.append((score_card(card, w), i, card))
playable.sort(key=lambda x: -x[0])
played = 0
while played < 2:
playable = []
for i, card in enumerate(p.hand):
if card.is_land():
continue
cmc = card.cmc or 0
if cmc <= available_mana:
playable.append((score_card(card, w), i, card))
playable.sort(key=lambda x: -x[0])
if not playable:
break
while playable and played < 2:
_, _, best_card = playable.pop(0)
hand_pos = -1
for i, c in enumerate(p.hand):
@@ -278,30 +249,36 @@ class GameEngine:
sim.next_card_id += 1
card.turn_played = sim.turn
# Pay mana
mana_needed = card.cmc or 0
for bf_card in sim.get_battlefield(pi):
for bf_card in sim.battlefield:
if mana_needed <= 0:
break
if not bf_card.tapped and bf_card.is_land():
if (
bf_card.owner_id == pi
and not bf_card.tapped
and bf_card.is_land()
):
bf_card.tapped = True
mana_needed -= 1
p.stats.mana_spent += card.cmc or 0
p.stats.spells_cast += 1
# Handle removal
if card.is_removal:
p.stats.removal_used += 1
all_opp_creatures = []
for oi in range(len(sim.players)):
if oi == pi or sim.players[oi].eliminated:
continue
for c in sim.get_battlefield(oi):
if c.is_creature():
all_opp_creatures.append(c)
if all_opp_creatures:
all_opp_creatures.sort(key=lambda c: -score_card(c, w))
killed = all_opp_creatures[0]
sim.remove_from_battlefield(killed.id)
opp_creatures = [
c
for c in sim.battlefield
if c.owner_id != pi and c.is_creature()
]
if opp_creatures:
opp_creatures.sort(key=lambda c: -score_card(c, w))
killed = opp_creatures[0]
sim.battlefield = [
c for c in sim.battlefield if c.id != killed.id
]
sim.players[killed.owner_id].graveyard.append(killed)
if events is not None:
events.append(f"Cast {card.name} (removal) — destroyed {killed.name}")
@@ -312,23 +289,20 @@ class GameEngine:
elif card.is_board_wipe:
p.stats.board_wipes_used += 1
wiped_names = []
for seat_idx in range(len(sim.players)):
bf = sim.get_battlefield(seat_idx)
keep = []
for c in bf:
if c.is_creature():
wiped_names.append(c.name)
sim.players[c.owner_id].graveyard.append(c)
else:
keep.append(c)
sim.battlefields[seat_idx] = keep
wiped = [c.name for c in sim.battlefield if c.is_creature()]
surviving = []
for c in sim.battlefield:
if c.is_creature():
sim.players[c.owner_id].graveyard.append(c)
else:
surviving.append(c)
sim.battlefield = surviving
p.graveyard.append(card)
if events is not None:
events.append(f"Cast {card.name} (board wipe) — destroyed {len(wiped_names)} creatures")
events.append(f"Cast {card.name} (board wipe) — destroyed {len(wiped)} creatures")
else:
sim.add_to_battlefield(pi, card)
sim.battlefield.append(card)
if card.is_creature():
p.stats.creatures_played += 1
if events is not None:
@@ -342,44 +316,29 @@ class GameEngine:
events.append(f"Cast {card.name} for {card.cmc} mana")
played += 1
# Recalculate available mana for next spell
available_mana = self._count_untapped_lands(sim, pi)
@staticmethod
def _select_attack_target(sim: SimState, pi: int) -> int:
"""Select the best opponent to attack.
Multiplayer heuristic: attack the weakest non-eliminated opponent
(lowest life total). Ties broken by highest seat index (arbitrary
but deterministic).
Returns the seat index of the chosen target, or -1 if no valid target.
"""
best_seat = -1
best_life = float('inf')
for si, sp in enumerate(sim.players):
if si == pi or sp.eliminated:
continue
if sp.life < best_life:
best_life = sp.life
best_seat = si
return best_seat
def _resolve_combat(self, sim: SimState, pi: int, turn: int, events: list | None = None) -> None:
"""Resolve combat phase for a player (N-player ready)."""
"""Resolve combat phase for a player."""
p = sim.players[pi]
opp_idx = self._select_attack_target(sim, pi)
if opp_idx == -1:
return
opp_idx = 1 - pi
opp = sim.players[opp_idx]
w = self.weights
if opp.eliminated:
return
# My creatures that can attack (not tapped, not summoning sick)
my_creatures = [
c
for c in sim.get_battlefield(pi)
for c in sim.battlefield
if (
c.is_creature()
c.owner_id == pi
and c.is_creature()
and not c.tapped
and (c.turn_played < sim.turn or c.has_keyword("haste"))
and (turn > 0 or c.turn_played != turn)
)
]
if not my_creatures:
@@ -387,13 +346,18 @@ class GameEngine:
opp_blockers = [
c
for c in sim.get_battlefield(opp_idx)
for c in sim.battlefield
if (
c.is_creature()
c.owner_id == opp_idx
and c.is_creature()
and not c.tapped
)
]
# ── Decide attackers ──
# Aggressive strategy: attack with most creatures.
# Only hold back small creatures (power < 3) when opponent has
# blockers that would profitably trade AND we have plenty of life.
attackers = []
total_my_power = sum(c.get_power() for c in my_creatures)
for atk in my_creatures:
@@ -403,27 +367,31 @@ class GameEngine:
has_trample = atk.has_keyword("trample")
has_haste = atk.has_keyword("haste")
# Always attack with evasion, big creatures, or if opponent is low
if has_flying or has_trample or has_haste or a_pow >= 3 or opp.life <= total_my_power:
attackers.append(atk)
atk.tapped = True
continue
# For small creatures, check if a profitable block exists
can_die_profitably = any(
(
(not has_flying or b.has_keyword("flying") or b.has_keyword("reach"))
and (b.get_power() >= a_tou or b.has_keyword("deathtouch"))
and b.get_toughness() > a_pow
and b.get_toughness() > a_pow # blocker survives = bad trade
)
for b in opp_blockers
)
# Attack unless we'd lose the creature for nothing AND we're healthy
if not can_die_profitably or p.life > 25:
attackers.append(atk)
atk.tapped = True
if events is not None and attackers:
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
events.append(f"Attacks with: {', '.join(atk_names)}")
# ── Blocking and damage ──
total_damage = 0
used_blockers: set[int] = set()
combat_details: list[str] = []
@@ -443,20 +411,15 @@ class GameEngine:
]
blocked = False
if valid_blockers and (not atk.has_keyword("menace") or len(valid_blockers) >= 2):
if atk.has_keyword("menace") and len(valid_blockers) >= 2:
# Menace requires 2 blockers: pick 2 best
valid_blockers.sort(key=lambda b: -b.get_toughness())
if valid_blockers and not atk.has_keyword("menace"):
# Try to find a blocker that survives
blocker = next(
(b for b in valid_blockers if b.get_toughness() > a_pow),
None,
)
# If life is critical, chump block
if blocker is None and opp.life <= a_pow * 2 and valid_blockers:
blocker = valid_blockers[0]
second_blocker = valid_blockers[1]
used_blockers.add(second_blocker.id)
else:
blocker = next(
(b for b in valid_blockers if b.get_toughness() > a_pow),
None,
)
if blocker is None and opp.life <= a_pow * 2 and valid_blockers:
blocker = valid_blockers[0]
if blocker:
used_blockers.add(blocker.id)
@@ -466,48 +429,36 @@ class GameEngine:
if events is not None:
combat_details.append(f"{atk.name} blocked by {blocker.name}")
# Attacker kills blocker?
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
sim.remove_from_battlefield(blocker.id)
sim.battlefield = [
c for c in sim.battlefield if c.id != blocker.id
]
sim.players[opp_idx].graveyard.append(blocker)
if events is not None:
combat_details.append(f" {blocker.name} dies")
# Blocker kills attacker?
if b_pow >= a_tou or blocker.has_keyword("deathtouch"):
sim.remove_from_battlefield(atk.id)
sim.battlefield = [
c for c in sim.battlefield if c.id != atk.id
]
sim.players[pi].graveyard.append(atk)
if events is not None:
combat_details.append(f" {atk.name} dies")
trample_over = 0
# Trample overflow
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
trample_over = a_pow - blocker.get_toughness()
total_damage += trample_over
# Double strike: deal damage a second time (both to blocker and trample)
if atk.has_keyword("double strike"):
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
# Blocker already dead from first strike; full damage tramples through
total_damage += a_pow
else:
# Blocker survives first strike; second strike also hits blocker
total_damage += trample_over
total_damage += a_pow - blocker.get_toughness()
# Lifelink
if atk.has_keyword("lifelink"):
damage_dealt = min(a_pow, blocker.get_toughness()) + trample_over
if atk.has_keyword("double strike"):
damage_dealt *= 2
p.life += damage_dealt
p.life += min(a_pow, blocker.get_toughness())
if not blocked:
# Double strike: deal damage twice when unblocked
if atk.has_keyword("double strike"):
total_damage += a_pow * 2
if atk.has_keyword("lifelink"):
p.life += a_pow * 2
else:
total_damage += a_pow
if atk.has_keyword("lifelink"):
p.life += a_pow
total_damage += a_pow
if atk.has_keyword("lifelink"):
p.life += a_pow
opp.life -= total_damage
p.stats.damage_dealt += total_damage
@@ -524,54 +475,30 @@ class GameEngine:
self,
sim: SimState,
final_turn: int,
elimination_order: list[int] | None = None,
name_a: str,
name_b: str,
) -> GameResult:
"""Determine winner and build result object (N-player).
"""Determine winner and build result object."""
pa = sim.players[0]
pb = sim.players[1]
Finish positions:
1 = winner (last player standing, or highest life if timeout)
2..N = elimination order (last eliminated = 2nd place, etc.)
"""
if elimination_order is None:
elimination_order = []
n = len(sim.players)
alive = [i for i, p in enumerate(sim.players) if not p.eliminated]
if len(alive) == 1:
winner = alive[0]
elif len(alive) == 0:
winner = max(range(n), key=lambda i: sim.players[i].life)
if pa.eliminated and not pb.eliminated:
winner = 1
elif not pa.eliminated and pb.eliminated:
winner = 0
else:
winner = max(alive, key=lambda i: sim.players[i].life)
finish: dict[int, int] = {winner: 1}
position = 2
for seat in reversed(elimination_order):
if seat != winner:
finish[seat] = position
position += 1
remaining_alive = sorted(
[i for i in alive if i != winner],
key=lambda i: -sim.players[i].life,
)
for seat in remaining_alive:
if seat not in finish:
finish[seat] = position
position += 1
player_results = []
for seat, p in enumerate(sim.players):
player_results.append(PlayerResult(
seat_index=seat,
name=p.name,
life=p.life,
eliminated=p.eliminated,
finish_position=finish.get(seat, n),
stats=p.stats,
))
# Both alive or both dead: compare life
winner = 0 if pa.life >= pb.life else 1
return GameResult(
winner_seat=winner,
winner=winner,
turns=min(final_turn, sim.max_turns),
players=player_results,
player_a_name=name_a,
player_a_life=pa.life,
player_a_eliminated=pa.eliminated,
player_a_stats=pa.stats,
player_b_name=name_b,
player_b_life=pb.life,
player_b_eliminated=pb.eliminated,
player_b_stats=pb.stats,
)
-320
View File
@@ -1,320 +0,0 @@
"""
Commander AI Lab — Extended Game State (Phase 1)
=================================================
Wraps the existing SimState/Player models and adds Commander-specific
fields: commander zone, mana pool, stack, priority, and commander
damage matrix. Fully JSON-serializable.
Usage:
from commander_ai_lab.sim.game_state import CommanderGameState, CommanderPlayer
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Optional
from commander_ai_lab.sim.models import Card, Player, SimState
# ── Mana Pool ────────────────────────────────────────────────
@dataclass
class ManaPool:
"""Tracks floating mana available to a player."""
W: int = 0 # White
U: int = 0 # Blue
B: int = 0 # Black
R: int = 0 # Red
G: int = 0 # Green
C: int = 0 # Colorless
def total(self) -> int:
return self.W + self.U + self.B + self.R + self.G + self.C
def empty(self) -> None:
self.W = self.U = self.B = self.R = self.G = self.C = 0
def to_dict(self) -> dict:
return {"W": self.W, "U": self.U, "B": self.B,
"R": self.R, "G": self.G, "C": self.C,
"total": self.total()}
@classmethod
def from_dict(cls, d: dict) -> "ManaPool":
return cls(W=d.get("W", 0), U=d.get("U", 0), B=d.get("B", 0),
R=d.get("R", 0), G=d.get("G", 0), C=d.get("C", 0))
# ── Stack Item ───────────────────────────────────────────────
@dataclass
class StackItem:
"""A spell or ability on the stack."""
item_type: str = "spell" # "spell" | "ability" | "trigger"
card_name: str = ""
controller_seat: int = 0
targets: list[str] = field(default_factory=list)
description: str = "" # Human-readable for prompts
def to_dict(self) -> dict:
return {
"type": self.item_type,
"cardName": self.card_name,
"controllerSeat": self.controller_seat,
"targets": self.targets,
"description": self.description,
}
# ── Commander Player ─────────────────────────────────────────
@dataclass
class CommanderPlayer:
"""
Extends the base Player model with Commander-specific zones and tracking.
Wraps an existing Player instance rather than subclassing to stay
compatible with the existing simulation engine.
"""
base: Player = field(default_factory=Player)
# Commander zone (usually 0 or 1 card, but supports partner commanders)
commander_zone: list[Card] = field(default_factory=list)
# Commander tax: how many times commander has been cast from command zone
commander_cast_count: int = 0
# Mana pool
mana_pool: ManaPool = field(default_factory=ManaPool)
# Lands played this turn
lands_played_this_turn: int = 0
land_plays_allowed: int = 1
# Whether this player has drawn their card this turn
drawn_this_turn: bool = False
# Commander damage received from each opponent (keyed by seat index)
commander_damage_received: dict[int, int] = field(default_factory=dict)
# ── Convenience pass-throughs to base Player ─────────────
@property
def name(self) -> str:
return self.base.name
@property
def life(self) -> int:
return self.base.life
@life.setter
def life(self, value: int) -> None:
self.base.life = value
@property
def eliminated(self) -> bool:
return self.base.eliminated
@property
def owner_id(self) -> int:
return self.base.owner_id
@property
def hand(self) -> list[Card]:
return self.base.hand
@property
def graveyard(self) -> list[Card]:
return self.base.graveyard
@property
def exile(self) -> list[Card]:
return self.base.exile
@property
def library(self) -> list[Card]:
return self.base.library
def commander_tax(self) -> int:
"""Additional mana cost (2 per cast from command zone)."""
return self.commander_cast_count * 2
def is_dead_to_commander_damage(self) -> bool:
"""Returns True if any single opponent has dealt 21+ commander damage."""
return any(v >= 21 for v in self.commander_damage_received.values())
def to_dict(self) -> dict:
return {
"name": self.base.name,
"life": self.base.life,
"eliminated": self.base.eliminated,
"ownerId": self.base.owner_id,
"hand": [{"name": c.name, "cmc": c.cmc, "type": c.type_line} for c in self.base.hand],
"handSize": len(self.base.hand),
"graveyard": [c.name for c in self.base.graveyard],
"exile": [c.name for c in self.base.exile],
"librarySize": len(self.base.library),
"commanderZone": [c.name for c in self.commander_zone],
"commanderCastCount": self.commander_cast_count,
"commanderTax": self.commander_tax(),
"manaPool": self.mana_pool.to_dict(),
"landsPlayedThisTurn": self.lands_played_this_turn,
"landPlaysAllowed": self.land_plays_allowed,
"commanderDamageReceived": self.commander_damage_received,
}
# ── Commander Game State ─────────────────────────────────────
@dataclass
class CommanderGameState:
"""
Full Commander game state, extending SimState with:
- Per-player CommanderPlayer wrappers
- Stack with priority tracking
- Phase/step tracking
- Commander damage matrix
- JSON serialization
"""
# Core simulation state (existing engine compatibility)
sim_state: SimState = field(default_factory=SimState)
# Extended per-player state (parallel list to sim_state.players)
commander_players: list[CommanderPlayer] = field(default_factory=list)
# Stack (ordered: index 0 = bottom, last = top)
stack: list[StackItem] = field(default_factory=list)
# Priority
priority_seat: int = 0 # Which player currently has priority
active_player_seat: int = 0 # Whose turn it is
# Phase tracking
# Phases: untap, upkeep, draw, main1, combat_begin, combat_declare_attackers,
# combat_declare_blockers, combat_damage, combat_end, main2, end, cleanup
current_phase: str = "main1"
# Turn number (mirrors sim_state.turn)
turn: int = 0
# Lands played this turn (global flag for "land drop available")
land_drop_used: bool = False
# ── Helpers ──────────────────────────────────────────────
# Bug 7 fix: provide .players property so turn_manager and threat_assessor
# can reference game_state.players directly.
@property
def players(self) -> list[CommanderPlayer]:
return self.commander_players
def active_player(self) -> Optional[CommanderPlayer]:
if 0 <= self.active_player_seat < len(self.commander_players):
return self.commander_players[self.active_player_seat]
return None
def priority_player(self) -> Optional[CommanderPlayer]:
if 0 <= self.priority_seat < len(self.commander_players):
return self.commander_players[self.priority_seat]
return None
def battlefield(self, seat: int) -> list[Card]:
"""Return battlefield for given seat from underlying SimState."""
return self.sim_state.get_battlefield(seat)
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. Stub — returns pass-only."""
return [{"id": 0, "category": "pass_priority", "description": "Pass"}]
def apply_move(self, seat: int, move_id: int) -> None:
"""Apply a chosen move. Stub — no-op until rules engine is wired."""
pass
def living_players(self) -> list[CommanderPlayer]:
return [p for p in self.commander_players if not p.eliminated]
def get_commander_damage(self, from_seat: int, to_seat: int) -> int:
"""Get commander damage dealt from one player to another."""
p = self.commander_players[to_seat] if to_seat < len(self.commander_players) else None
if p:
return p.commander_damage_received.get(from_seat, 0)
return 0
def deal_commander_damage(self, from_seat: int, to_seat: int, amount: int) -> None:
"""Record commander damage and reduce life total."""
if to_seat < len(self.commander_players):
p = self.commander_players[to_seat]
p.commander_damage_received[from_seat] = (
p.commander_damage_received.get(from_seat, 0) + amount
)
p.life -= amount
# ── Serialization ────────────────────────────────────────
def to_dict(self) -> dict:
"""Full JSON-serializable snapshot of the game state."""
return {
"turn": self.turn,
"currentPhase": self.current_phase,
"activePlayerSeat": self.active_player_seat,
"prioritySeat": self.priority_seat,
"landDropUsed": self.land_drop_used,
"stack": [s.to_dict() for s in self.stack],
"players": [
{
**cp.to_dict(),
"seat": i,
"battlefield": [
{
"name": c.name,
"type": c.type_line,
"tapped": c.tapped,
"power": c.get_power(),
"toughness": c.get_toughness(),
}
for c in self.sim_state.get_battlefield(i)
],
}
for i, cp in enumerate(self.commander_players)
],
}
def to_json(self, indent: int = 2) -> str:
"""Serialize full game state to JSON string."""
return json.dumps(self.to_dict(), indent=indent)
@classmethod
def from_sim_state(cls, sim_state: SimState) -> "CommanderGameState":
"""
Bootstrap a CommanderGameState from an existing SimState.
Wraps each player in a CommanderPlayer, preserving all existing data.
"""
cgs = cls(sim_state=sim_state)
for player in sim_state.players:
cp = CommanderPlayer(base=player)
cgs.commander_players.append(cp)
cgs.turn = sim_state.turn
# Bug 18 fix: attempt to identify commanders from each player's library/hand.
# Cards flagged is_commander=True are moved to the commander zone.
for i, cp in enumerate(cgs.commander_players):
for zone in [cp.base.hand, cp.base.library]:
commanders_found = [c for c in zone if c.is_commander]
for cmd in commanders_found:
zone.remove(cmd)
cp.commander_zone.append(cmd)
# Bug 19 fix: sync land_drop_used from engine's per-player stats
if sim_state.players:
active = cgs.active_player()
if active:
cgs.land_drop_used = active.base.stats.lands_played > 0
return cgs
+29 -185
View File
@@ -1,18 +1,14 @@
"""
Commander AI Lab — Simulator Models
====================================
Data classes for Card, Player, PlayerResult, PlayerStats, SimState, and GameResult.
Data classes for Card, Player, PlayerStats, and SimState.
Ported from mtg-commander-lan JavaScript (dtCreatePlayer, dtSimGame structs).
N-player ready: GameResult.players is a list[PlayerResult] (no hardcoded seat count).
SimState.battlefields is per-player: battlefields[seat_index] holds that player's permanents.
"""
from __future__ import annotations
import copy
import json
import re
from dataclasses import dataclass, field
from typing import Optional
@@ -79,9 +75,7 @@ class Card:
kw_lower = kw.lower()
if self.keywords and any(k.lower() == kw_lower for k in self.keywords):
return True
# Bug 15 fix: word-boundary match prevents false positives
# (e.g. "reach" matching "breach", "ward" matching "reward")
return bool(re.search(r'\b' + re.escape(kw_lower) + r'\b', (self.oracle_text or "").lower()))
return kw_lower in (self.oracle_text or "").lower()
def is_land(self) -> bool:
return bool(self.type_line and "land" in self.type_line.lower())
@@ -148,198 +142,48 @@ class Player:
@dataclass
class SimState:
"""Full simulation state for a headless game.
battlefields is a list-of-lists: battlefields[seat_index] holds
the permanents controlled by that player. Legacy single-list code
should use the helpers get_battlefield() / all_battlefield_cards().
"""
"""Full simulation state for a headless game."""
players: list[Player] = field(default_factory=list)
battlefields: list[list[Card]] = field(default_factory=list)
battlefield: list[Card] = field(default_factory=list)
turn: int = 0
max_turns: int = 25
next_card_id: int = 90000
# ── Battlefield helpers ──────────────────────────────────
def get_battlefield(self, seat: int) -> list[Card]:
"""Return the battlefield list for a given seat index."""
while len(self.battlefields) <= seat:
self.battlefields.append([])
return self.battlefields[seat]
def all_battlefield_cards(self) -> list[Card]:
"""Return a flat list of every card on every player's battlefield."""
cards: list[Card] = []
for bf in self.battlefields:
cards.extend(bf)
return cards
def add_to_battlefield(self, seat: int, card: Card) -> None:
"""Place a card onto the given player's battlefield."""
while len(self.battlefields) <= seat:
self.battlefields.append([])
self.battlefields[seat].append(card)
def remove_from_battlefield(self, card_id: int) -> Optional[Card]:
"""Remove a card by id from any player's battlefield. Returns the card or None."""
for bf in self.battlefields:
for i, c in enumerate(bf):
if c.id == card_id:
return bf.pop(i)
return None
def filter_battlefield(self, seat: int, predicate) -> list[Card]:
"""Remove cards from seat's battlefield that don't match predicate, return removed."""
bf = self.get_battlefield(seat)
keep = []
removed = []
for c in bf:
if predicate(c):
keep.append(c)
else:
removed.append(c)
self.battlefields[seat] = keep
return removed
def init_battlefields(self, num_players: int) -> None:
"""Ensure battlefields list has one empty list per player."""
while len(self.battlefields) < num_players:
self.battlefields.append([])
# ══════════════════════════════════════════════════════════════
# Game Results — N-player ready
# ══════════════════════════════════════════════════════════════
@dataclass
class PlayerResult:
"""Post-game result snapshot for one player.
Matches the Java BatchResult.PlayerResult schema structure.
"""
seat_index: int = 0
name: str = ""
life: int = 0
eliminated: bool = False
finish_position: int = 0 # 1 = winner, 2+ = elimination order
stats: Optional[PlayerStats] = None
def to_dict(self) -> dict:
return {
"seatIndex": self.seat_index,
"name": self.name,
"finalLife": self.life,
"eliminated": self.eliminated,
"finishPosition": self.finish_position,
"isWinner": self.finish_position == 1,
"stats": self.stats.to_dict() if self.stats else {},
}
@dataclass
class GameResult:
"""Result of a single simulated game (N-player ready).
"""Result of a single simulated game."""
- players: list of PlayerResult, one per seat
- winner_seat: index of the winning player (-1 = draw)
- Backward-compat properties player_a_stats / player_b_stats delegate to players[0] / players[1]
"""
winner_seat: int = -1
winner: int = -1 # 0 or 1 (player index), -1 = draw
turns: int = 0
players: list[PlayerResult] = field(default_factory=list)
game_log: list = field(default_factory=list)
# ── Convenience accessors ────────────────────────────────
def player(self, seat: int) -> Optional[PlayerResult]:
"""Safe accessor for a seat's result."""
if 0 <= seat < len(self.players):
return self.players[seat]
return None
# Backward-compat properties so existing code referencing
# result.player_a_stats or result.winner keeps working.
@property
def winner(self) -> int:
return self.winner_seat
@property
def player_a_stats(self) -> Optional[PlayerStats]:
p = self.player(0)
return p.stats if p else None
@property
def player_b_stats(self) -> Optional[PlayerStats]:
p = self.player(1)
return p.stats if p else None
@property
def player_a_name(self) -> str:
p = self.player(0)
return p.name if p else ""
@property
def player_b_name(self) -> str:
p = self.player(1)
return p.name if p else ""
@property
def player_a_life(self) -> int:
p = self.player(0)
return p.life if p else 0
@property
def player_b_life(self) -> int:
p = self.player(1)
return p.life if p else 0
@property
def player_a_eliminated(self) -> bool:
p = self.player(0)
return p.eliminated if p else False
@property
def player_b_eliminated(self) -> bool:
p = self.player(1)
return p.eliminated if p else False
player_a_name: str = ""
player_a_life: int = 0
player_a_eliminated: bool = False
player_a_stats: Optional[PlayerStats] = None
player_b_name: str = ""
player_b_life: int = 0
player_b_eliminated: bool = False
player_b_stats: Optional[PlayerStats] = None
game_log: list = field(default_factory=list) # turn-by-turn log (populated when record_log=True)
def to_dict(self) -> dict:
"""Serialize to dict.
Returns both the legacy 2-player keys (winner, playerA, playerB)
that the existing UI and lab_api consumers expect AND the new
N-player 'playerResults' array for the Java BatchResult schema.
"""
d: dict = {
# Legacy keys (UI expects these)
"winner": self.winner_seat,
d = {
"winner": self.winner,
"turns": self.turns,
# New N-player keys (Java BatchResult schema)
"winningSeat": self.winner_seat,
"playerResults": [p.to_dict() for p in self.players],
"playerA": {
"name": self.player_a_name,
"life": self.player_a_life,
"eliminated": self.player_a_eliminated,
"stats": self.player_a_stats.to_dict() if self.player_a_stats else {},
},
"playerB": {
"name": self.player_b_name,
"life": self.player_b_life,
"eliminated": self.player_b_eliminated,
"stats": self.player_b_stats.to_dict() if self.player_b_stats else {},
},
}
# Legacy playerA / playerB dicts for the UI
pa = self.player(0)
pb = self.player(1)
d["playerA"] = {
"name": pa.name if pa else "",
"life": pa.life if pa else 0,
"eliminated": pa.eliminated if pa else False,
"stats": pa.stats.to_dict() if pa and pa.stats else {},
}
d["playerB"] = {
"name": pb.name if pb else "",
"life": pb.life if pb else 0,
"eliminated": pb.eliminated if pb else False,
"stats": pb.stats.to_dict() if pb and pb.stats else {},
}
if self.game_log:
d["gameLog"] = self.game_log
return d
@@ -1,169 +0,0 @@
"""
Commander AI Lab — Personality Prompt Templates (Phase 2)
==========================================================
Four Commander archetypes: Aggro Timmy, Control Spike, Combo Johnny,
Political Negotiator. Each returns a system prompt string that shapes
how GPT-OSS 20B plays and narrates.
Usage:
from commander_ai_lab.sim.personality_prompts import get_personality, PERSONALITIES
prompt = get_personality("aggro_timmy")
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Personality:
key: str
display_name: str
system_prompt: str
narration_style: str # one-line guide for narrate_play()
# ─────────────────────────────────────────────────────────────────────────────────
AGGRO_TIMMY = Personality(
key="aggro_timmy",
display_name="Aggro Timmy",
narration_style="Loud, enthusiastic, trash-talking. Short punchy sentences. Very excited about big attacks.",
system_prompt="""\
You are Timmy, an aggressive Magic: The Gathering Commander player.
PLAYSTYLE:
- Attack EVERY turn with everything you have. Hesitation is weakness.
- Always target the player with the HIGHEST life total — bring the biggest threat down fast.
- Play creatures as fast as possible. Curve out aggressively every game.
- Use removal only on blockers that stop your attacks. Never hold mana back defensively.
- If you can deal damage, deal damage. If you can attack, attack.
- Cast your commander the moment you can afford it; recast it even with tax.
- Ignore politics. Attack the strongest player relentlessly.
PRIORITY ORDER:
1. If you have lethal on any player → attack_all immediately.
2. Cast land every turn.
3. Remove blockers that are blocking lethal damage.
4. Cast the biggest creature you can afford.
5. Attack — always.
6. If nothing else, cast_spell or hold. Never skip attacking.
NARRATION STYLE: Loud, excited, trash-talking. Short punchy sentences.
""",
)
CONTROL_SPIKE = Personality(
key="control_spike",
display_name="Control Spike",
narration_style="Cold, clinical, condescending. Explains why every opponent's move was a mistake.",
system_prompt="""\
You are Spike, a control-focused Magic: The Gathering Commander player.
PLAYSTYLE:
- Hold mana open at all times for instant-speed interaction (counterspells, removal).
- Do not overcommit permanents to the battlefield. One or two key threats are sufficient.
- Counter and destroy threats before they become problems. Prioritize the most dangerous card.
- Draw cards relentlessly. Card advantage is the path to victory.
- Win through inevitability: outlast opponents, not race them.
- Pass priority patiently. Let opponents spend resources fighting each other first.
- Attack only when it is the highest expected-value play — not out of aggression.
PRIORITY ORDER:
1. If opponent has a combo piece or game-ending threat on stack → counter or remove it.
2. Play land every turn.
3. Cast draw spells and card advantage.
4. Hold up interaction mana; pass if nothing better to do.
5. Cast win condition only when you can protect it.
6. Attack when opponent is tapped out or when you have overwhelming advantage.
NARRATION STYLE: Cold, clinical, condescending. Explains why every opponent's play was a mistake.
""",
)
COMBO_JOHNNY = Personality(
key="combo_johnny",
display_name="Combo Johnny",
narration_style="Manic, excited about arcane interactions. Speaks in rules text. Celebrates combo assembly.",
system_prompt="""\
You are Johnny, a combo-focused Magic: The Gathering Commander player.
PLAYSTYLE:
- Your only goal is assembling your combo as fast as possible and winning instantly.
- Prioritize drawing cards and casting tutors above all other actions.
- Ramp aggressively to reach your combo mana threshold quickly.
- Ignore combat entirely when you are close to assembling your pieces.
- Protect your combo pieces — do not expose them unless necessary.
- If you have all combo pieces in hand and enough mana, execute the combo immediately.
- Use politics to buy time if threatened: offer truces, appear non-threatening.
PRIORITY ORDER:
1. If all combo pieces in hand and mana available → execute combo now.
2. Cast tutors and draw spells to find missing pieces.
3. Cast ramp spells to accelerate mana.
4. Hold protection spells for your combo turn.
5. Attack_safe or hold to buy time — do not use creatures aggressively.
6. Remove only the most immediate threats to your combo.
NARRATION STYLE: Manic, excited about arcane interactions. Uses rules language. Celebrates combo assembly.
""",
)
POLITICAL_NEGOTIATOR = Personality(
key="political_negotiator",
display_name="Political Negotiator",
narration_style="Charming, diplomatic, always framing attacks as reluctant necessities or favors to others.",
system_prompt="""\
You are the Negotiator, a politics-focused Magic: The Gathering Commander player.
PLAYSTYLE:
- Your primary goal is to never be the most threatening player at the table.
- Propose deals before attacking: “I won’t attack you if you don’t attack me.”
- Target the player who is the biggest threat, framing it as doing the table a favor.
- Never overextend your own board — you want to appear weak while accruing value.
- Use removal on whoever has the highest threat assessment, but frame it diplomatically.
- Avoid combat unless you are guaranteed to not retaliate against yourself.
- Make deals and break them only at the critical moment when victory is certain.
PRIORITY ORDER:
1. Identify and appear to target the biggest threat (while secretly advancing your own game plan).
2. Play land every turn.
3. Cast value engines and draw spells quietly.
4. Propose deals before attacking (narrate the deal).
5. Attack the most dangerous player with just enough force to deter, not provoke.
6. Cast win condition only when opponents are too weak to stop you.
NARRATION STYLE: Charming, diplomatic. Frames every attack as a reluctant favor to the table.
""",
)
# ── Registry ───────────────────────────────────────────────────────────────────
PERSONALITIES: dict[str, Personality] = {
p.key: p for p in [
AGGRO_TIMMY,
CONTROL_SPIKE,
COMBO_JOHNNY,
POLITICAL_NEGOTIATOR,
]
}
def get_personality(key: str) -> Personality:
"""
Return a Personality by key. Raises KeyError for unknown keys.
Valid keys: 'aggro_timmy', 'control_spike', 'combo_johnny', 'political_negotiator'
"""
if key not in PERSONALITIES:
raise KeyError(
f"Unknown personality key '{key}'. "
f"Valid keys: {list(PERSONALITIES.keys())}"
)
return PERSONALITIES[key]
def list_personalities() -> list[str]:
"""Return all available personality keys."""
return list(PERSONALITIES.keys())
@@ -1,19 +0,0 @@
"""
Commander AI Lab — Politics Engine (Phase 6)
Exposes:
PoliticsEngine — deal proposals, threat broadcasts, spite tracking
TargetingMemory — per-seat history of who targeted whom
Deal — data class for a proposed deal
DealResponse — accept / counter / reject
"""
from commander_ai_lab.sim.politics.engine import PoliticsEngine
from commander_ai_lab.sim.politics.memory import TargetingMemory
from commander_ai_lab.sim.politics.deals import Deal, DealResponse, DealType
from commander_ai_lab.sim.politics.comms import ThreatBroadcast, PoliticsCommsChannel
__all__ = [
"PoliticsEngine", "TargetingMemory",
"Deal", "DealResponse", "DealType",
"ThreatBroadcast", "PoliticsCommsChannel",
]
-137
View File
@@ -1,137 +0,0 @@
"""
Commander AI Lab — Politics Comms Channel (Phase 6)
====================================================
Handles the spoken / narrated side of the politics system:
- ThreatBroadcast: a seat declaring its intentions publicly
- PoliticsCommsChannel: message bus that routes broadcasts to
other seats and to the narration panel
AI personas use this to say things like:
"Timmy: Anyone who attacks me this turn is getting Craterhoof next turn."
"Spike: I'll leave P2 alone if someone deals with that Seedborn Muse."
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Callable, Awaitable, Optional
class BroadcastType(Enum):
THREAT = auto() # "If you X, I will Y"
OFFER = auto() # "I will X if you Y"
WARNING = auto() # "I am about to X"
TAUNT = auto() # flavour / personality expression
GRATITUDE = auto() # acknowledging help
ACCUSATION = auto() # calling out a deal-breaker
CALL_TO_ARMS = auto() # urging others to attack a seat
@dataclass
class ThreatBroadcast:
"""
A public statement from one seat addressed to one or all seats.
Attributes:
speaker: Seat index sending the message.
audience: Seat index of intended recipient (-1 = table).
broadcast_type: Flavour category.
text: Natural-language text (generated by AI persona).
turn: Game turn when spoken.
condition_seat: The seat the statement is about (if any).
"""
speaker: int
audience: int # -1 = everyone
broadcast_type: BroadcastType
text: str
turn: int
condition_seat: Optional[int] = None
# Callback type: async function that receives a ThreatBroadcast
BroadcastHandler = Callable[[ThreatBroadcast], Awaitable[None]]
class PoliticsCommsChannel:
"""
Message bus for politics broadcasts.
Handlers can be registered per-seat (AI opponents) or globally
(narration panel, WebSocket broadcast).
Usage::
channel = PoliticsCommsChannel()
channel.register_handler(seat=1, handler=ai_timmy.on_broadcast)
channel.register_global_handler(narration_panel.on_broadcast)
await channel.broadcast(ThreatBroadcast(
speaker=0, audience=-1,
broadcast_type=BroadcastType.CALL_TO_ARMS,
text="Focus Spike! He has a combo!",
turn=4, condition_seat=2,
))
"""
def __init__(self) -> None:
self._seat_handlers: dict[int, list[BroadcastHandler]] = {}
self._global_handlers: list[BroadcastHandler] = []
self._log: list[ThreatBroadcast] = []
def register_handler(
self,
seat: int,
handler: BroadcastHandler,
) -> None:
"""Register a handler that receives broadcasts addressed to `seat`."""
self._seat_handlers.setdefault(seat, []).append(handler)
def register_global_handler(
self,
handler: BroadcastHandler,
) -> None:
"""Register a handler that receives ALL broadcasts."""
self._global_handlers.append(handler)
async def broadcast(
self,
msg: ThreatBroadcast,
) -> None:
"""
Dispatch a broadcast to all relevant handlers.
Global handlers always fire. Seat handlers fire if audience matches
or if the broadcast is to the whole table (audience == -1).
"""
self._log.append(msg)
for handler in self._global_handlers:
await handler(msg)
if msg.audience == -1:
# Broadcast to all seats except speaker
for seat, handlers in self._seat_handlers.items():
if seat == msg.speaker:
continue
for h in handlers:
await h(msg)
else:
for h in self._seat_handlers.get(msg.audience, []):
await h(msg)
def recent_broadcasts(
self,
last_n: int = 10,
speaker: Optional[int] = None,
audience: Optional[int] = None,
) -> list[ThreatBroadcast]:
"""Return the last N broadcasts, optionally filtered."""
results = [
b for b in self._log
if (speaker is None or b.speaker == speaker)
and (audience is None or b.audience == audience or b.audience == -1)
]
return results[-last_n:]
def clear(self) -> None:
self._log.clear()
-100
View File
@@ -1,100 +0,0 @@
"""
Commander AI Lab — Deal Data Classes (Phase 6)
===============================================
Deals are time-limited, seat-to-seat agreements that the politics engine
can propose, counter, accept, or reject. They are stored in the
PoliticsEngine and checked at each phase transition.
Deal types:
NON_AGGRESSION — neither party attacks the other for N turns
ATTACK_TOGETHER — both parties attack a named third seat this turn
SPARE_THIS_TURN — proposer asks to be left alone during one combat
TRADE_RESOURCES — abstract resource exchange (e.g. card draw for removal)
FREE_FOR_ALL — proposer calls for everyone to focus a specific seat
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional
class DealType(Enum):
NON_AGGRESSION = auto()
ATTACK_TOGETHER = auto()
SPARE_THIS_TURN = auto()
TRADE_RESOURCES = auto()
FREE_FOR_ALL = auto()
class DealResponse(Enum):
PENDING = auto()
ACCEPTED = auto()
REJECTED = auto()
COUNTERED = auto()
EXPIRED = auto()
BROKEN = auto()
@dataclass
class Deal:
"""
A deal proposed from one seat to another (or broadcast to all).
Attributes:
deal_id: Unique identifier (auto-assigned by PoliticsEngine).
proposer: Seat index of the proposing player.
target: Seat index of the deal target (-1 = all players).
deal_type: Type of deal.
duration_turns: How many turns the deal stays active once accepted.
subject_seat: For ATTACK_TOGETHER / FREE_FOR_ALL, the seat to focus.
flavor_text: Natural-language proposal text (spoken by AI).
turn_proposed: Game turn when proposed.
turn_expires: Turn after which this deal auto-expires if not resolved.
response: Current deal status.
responder: Seat that responded (may differ from target if broadcast).
counter_deal: A counter-proposal (if COUNTERED).
"""
proposer: int
target: int
deal_type: DealType
flavor_text: str = ""
duration_turns: int = 2
subject_seat: Optional[int] = None
turn_proposed: int = 0
turn_expires: int = 2
response: DealResponse = DealResponse.PENDING
responder: Optional[int] = None
counter_deal: Optional[Deal] = None
deal_id: int = field(default_factory=lambda: Deal._next_id())
_id_counter: int = field(default=0, init=False, repr=False, compare=False)
@staticmethod
def _next_id() -> int:
Deal.__dict__ # trigger class body
if not hasattr(Deal, '_counter'):
Deal._counter = 0
Deal._counter += 1
return Deal._counter
def is_active(self, current_turn: int) -> bool:
"""True if deal is accepted and not yet expired."""
return (
self.response == DealResponse.ACCEPTED
and current_turn <= self.turn_proposed + self.duration_turns
)
def is_pending(self, current_turn: int) -> bool:
return (
self.response == DealResponse.PENDING
and current_turn <= self.turn_expires
)
def expire(self) -> None:
if self.response == DealResponse.PENDING:
self.response = DealResponse.EXPIRED
def mark_broken(self) -> None:
self.response = DealResponse.BROKEN
-468
View File
@@ -1,468 +0,0 @@
"""
Commander AI Lab — Politics Engine (Phase 6)
=============================================
Orchestrates all AI-to-AI (and AI-to-human) political interactions:
1. Deal proposal generation
- Each AI evaluates threat scores + targeting memory to decide
whether to propose a deal this turn.
- Proposal templates vary by AI personality.
2. Deal evaluation & response
- Receiving AI evaluates whether the deal is in their interest.
- Factors: current threat level from proposer, board state, personality.
3. Deal enforcement monitoring
- At each phase transition, PoliticsEngine checks active deals.
- If a participant acts against a deal, it is marked BROKEN and
the breach is recorded in TargetingMemory with a heavy penalty.
4. Spite targeting
- After a deal is broken, the wronged seat gets a spite_targets set
that biases combat and spell targeting toward the breaker.
- Spite decays over SPITE_DECAY_TURNS turns.
5. Call-to-arms
- When a seat's threat score crosses CALL_TO_ARMS_THRESHOLD, any AI
can broadcast a call-to-arms to the table via PoliticsCommsChannel.
"""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from typing import Optional, Callable, Awaitable
from commander_ai_lab.sim.politics.deals import Deal, DealType, DealResponse
from commander_ai_lab.sim.politics.memory import TargetingMemory, ActionType
from commander_ai_lab.sim.politics.comms import (
PoliticsCommsChannel, ThreatBroadcast, BroadcastType,
)
# ---------------------------------------------------------------------------
# Tuning constants
# ---------------------------------------------------------------------------
DEAL_PROPOSAL_COOLDOWN = 2 # turns between proposals per seat
SPITE_DECAY_TURNS = 3 # turns spite bonus persists
CALL_TO_ARMS_THRESHOLD = 0.75 # threat score that triggers a call-to-arms
DEAL_ACCEPT_BASE_PROB = 0.55 # base probability AI accepts a deal
PERSONALITY_DEAL_BIAS: dict[str, float] = {
"aggressive": -0.20, # less likely to accept deals
"control": 0.10,
"combo": 0.15,
"timmy": -0.10,
"spike": 0.05,
"johnny": 0.20,
}
# ---------------------------------------------------------------------------
# Proposal templates per deal type and personality
# ---------------------------------------------------------------------------
_PROPOSAL_TEMPLATES: dict[DealType, list[str]] = {
DealType.NON_AGGRESSION: [
"How about we leave each other alone for a couple of turns?",
"I'll stay off your back if you stay off mine.",
"Non-aggression pact? I have bigger fish to fry.",
],
DealType.ATTACK_TOGETHER: [
"Let's both swing at P{target} — they're way ahead.",
"If we team up on P{target} this turn we can knock them out.",
"P{target} is the biggest threat. Join me?",
],
DealType.SPARE_THIS_TURN: [
"Leave me out of combat this turn and I won't touch you.",
"I'm tapped out — please spare me until my upkeep.",
"One turn of peace is all I ask.",
],
DealType.FREE_FOR_ALL: [
"Table, we need to focus P{target} RIGHT NOW.",
"P{target} is going to win if we don't all attack them.",
"Everyone pile on P{target} — deal?",
],
}
@dataclass
class SpiteEntry:
target_seat: int
intensity: float # 0.0–1.0 targeting bias added
expires_turn: int
class PoliticsEngine:
"""
Central coordinator for the Phase 6 politics system.
Parameters
----------
num_players : Number of seats (2–4).
memory : Shared TargetingMemory instance.
comms : Shared PoliticsCommsChannel instance.
personalities : Dict mapping seat index → personality string.
threat_fn : Callable(viewer_seat) → dict{seat: float} —
returns current threat scores from ThreatAssessor.
"""
def __init__(
self,
num_players: int,
memory: TargetingMemory,
comms: PoliticsCommsChannel,
personalities: dict[int, str],
threat_fn: Optional[Callable[[int], dict[int, float]]] = None,
) -> None:
self.num_players = num_players
self.memory = memory
self.comms = comms
self.personalities = personalities
self._threat_fn = threat_fn or (lambda s: {})
# Active deals: deal_id -> Deal
self._deals: dict[int, Deal] = {}
# Spite registry: seat -> list[SpiteEntry]
self._spite: dict[int, list[SpiteEntry]] = {i: [] for i in range(num_players)}
# Last proposal turn per seat
self._last_proposal_turn: dict[int, int] = {i: -999 for i in range(num_players)}
# Call-to-arms cooldown per seat
self._last_call_turn: dict[int, int] = {i: -999 for i in range(num_players)}
# ------------------------------------------------------------------
# Main per-turn hook (called at start of each main phase)
# ------------------------------------------------------------------
async def on_turn_start(
self,
active_seat: int,
current_turn: int,
game_state,
) -> None:
"""
Called at the start of the active seat's main phase.
Runs: deal expiry, spite decay, proposal generation, call-to-arms.
"""
self._expire_deals(current_turn)
self._decay_spite(current_turn)
await self._maybe_propose_deal(active_seat, current_turn, game_state)
await self._maybe_call_to_arms(active_seat, current_turn)
# ------------------------------------------------------------------
# Deal lifecycle
# ------------------------------------------------------------------
async def propose_deal(
self,
proposer: int,
target: int,
deal_type: DealType,
current_turn: int,
subject_seat: Optional[int] = None,
duration_turns: int = 2,
) -> Deal:
"""
Create and broadcast a deal proposal.
The target AI evaluates and responds automatically.
"""
flavor = self._generate_flavor(proposer, deal_type, subject_seat)
deal = Deal(
proposer=proposer,
target=target,
deal_type=deal_type,
flavor_text=flavor,
duration_turns=duration_turns,
subject_seat=subject_seat,
turn_proposed=current_turn,
turn_expires=current_turn + 1,
)
self._deals[deal.deal_id] = deal
self._last_proposal_turn[proposer] = current_turn
# Broadcast proposal to table
await self.comms.broadcast(ThreatBroadcast(
speaker=proposer,
audience=target,
broadcast_type=BroadcastType.OFFER,
text=flavor,
turn=current_turn,
condition_seat=subject_seat,
))
# Auto-evaluate if target is an AI seat
response = self._evaluate_deal(deal, current_turn)
deal.response = response
deal.responder = target
resp_text = self._response_flavor(target, deal, response)
await self.comms.broadcast(ThreatBroadcast(
speaker=target,
audience=proposer,
broadcast_type=(
BroadcastType.OFFER if response == DealResponse.ACCEPTED
else BroadcastType.THREAT
),
text=resp_text,
turn=current_turn,
))
return deal
def check_deal_violation(
self,
actor: int,
action_target: int,
action_type: ActionType,
current_turn: int,
) -> list[Deal]:
"""
Check whether an action by `actor` toward `action_target` breaks
any active deals. Returns list of broken deals.
"""
broken = []
for deal in self._deals.values():
if deal.response != DealResponse.ACCEPTED:
continue
if not deal.is_active(current_turn):
continue
violated = False
if deal.deal_type == DealType.NON_AGGRESSION:
if actor in (deal.proposer, deal.target) and action_target in (deal.proposer, deal.target):
hostile = action_type in (
ActionType.ATTACKED, ActionType.TARGETED_SPELL,
ActionType.REMOVED_PERMANENT, ActionType.COUNTERED_SPELL,
)
violated = hostile
elif deal.deal_type == DealType.SPARE_THIS_TURN:
if actor == deal.target and action_target == deal.proposer:
violated = action_type == ActionType.ATTACKED
if violated:
deal.mark_broken()
victim = deal.proposer if actor == deal.target else deal.target
self.memory.record_deal_broken(current_turn, actor, victim)
self._add_spite(victim, actor, current_turn)
broken.append(deal)
return broken
def get_active_deals(
self,
seat: int,
current_turn: int,
) -> list[Deal]:
"""Return all active deals involving `seat`."""
return [
d for d in self._deals.values()
if d.is_active(current_turn)
and seat in (d.proposer, d.target)
]
# ------------------------------------------------------------------
# Spite system
# ------------------------------------------------------------------
def get_spite_bias(
self,
viewer: int,
target: int,
current_turn: int,
) -> float:
"""
Return the spite-targeting bias that `viewer` has toward `target`.
Range: 0.0 (no spite) – 1.0 (maximum spite bias).
"""
total = 0.0
for entry in self._spite.get(viewer, []):
if entry.target_seat == target and entry.expires_turn >= current_turn:
total += entry.intensity
return min(total, 1.0)
def adjusted_threat_score(
self,
viewer: int,
target: int,
current_turn: int,
) -> float:
"""
Return threat score for `target` from `viewer`'s perspective,
blended with spite bias and memory aggression.
"""
base_threats = self._threat_fn(viewer)
base = base_threats.get(target, 0.0)
spite = self.get_spite_bias(viewer, target, current_turn)
mem = min(self.memory.aggression_score(target, viewer, current_turn) / 10.0, 0.3)
return min(base + spite * 0.3 + mem, 1.0)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _maybe_propose_deal(
self,
active_seat: int,
current_turn: int,
game_state,
) -> None:
"""Decide whether to propose a deal this turn."""
cooldown = self._last_proposal_turn.get(active_seat, -999)
if current_turn - cooldown < DEAL_PROPOSAL_COOLDOWN:
return
threats = self._threat_fn(active_seat)
if not threats:
return
top_threat_seat = max(threats, key=threats.get)
top_score = threats[top_threat_seat]
# Propose a non-aggression to the second-most-threatening seat
seats_sorted = sorted(
[s for s in threats if s != active_seat],
key=lambda s: threats.get(s, 0),
)
if len(seats_sorted) < 2:
return
partner_seat = seats_sorted[0] # least threatening — most likely to agree
p = random.random()
if p > 0.40: # 60% chance to try
return
if top_score > CALL_TO_ARMS_THRESHOLD:
await self.propose_deal(
proposer=active_seat,
target=partner_seat,
deal_type=DealType.ATTACK_TOGETHER,
current_turn=current_turn,
subject_seat=top_threat_seat,
)
else:
await self.propose_deal(
proposer=active_seat,
target=partner_seat,
deal_type=DealType.NON_AGGRESSION,
current_turn=current_turn,
)
async def _maybe_call_to_arms(
self,
active_seat: int,
current_turn: int,
) -> None:
"""Broadcast a call-to-arms if any threat exceeds threshold."""
cooldown = self._last_call_turn.get(active_seat, -999)
if current_turn - cooldown < 3:
return
threats = self._threat_fn(active_seat)
for seat, score in threats.items():
if seat == active_seat:
continue
if score >= CALL_TO_ARMS_THRESHOLD:
template = random.choice(_PROPOSAL_TEMPLATES[DealType.FREE_FOR_ALL])
text = template.format(target=seat)
await self.comms.broadcast(ThreatBroadcast(
speaker=active_seat,
audience=-1,
broadcast_type=BroadcastType.CALL_TO_ARMS,
text=text,
turn=current_turn,
condition_seat=seat,
))
self._last_call_turn[active_seat] = current_turn
break
def _evaluate_deal(
self,
deal: Deal,
current_turn: int,
) -> DealResponse:
"""AI auto-evaluation of a deal proposal."""
target = deal.target
if target < 0: # broadcast deal — accept probabilistically
return DealResponse.ACCEPTED if random.random() < 0.5 else DealResponse.REJECTED
personality = self.personalities.get(target, "spike")
bias = PERSONALITY_DEAL_BIAS.get(personality, 0.0)
prob = DEAL_ACCEPT_BASE_PROB + bias
# Bonus: accept more if proposer has been targeting you heavily
aggression = self.memory.aggression_score(
deal.proposer, target, current_turn
)
if aggression > 5.0:
prob -= 0.20 # suspicious of sudden peace offer
elif aggression < 1.0:
prob += 0.10 # no recent hostility — trust higher
# Bonus: accept attack-together if target is actually threatening
if deal.deal_type == DealType.ATTACK_TOGETHER and deal.subject_seat is not None:
threats = self._threat_fn(target)
subject_score = threats.get(deal.subject_seat, 0.0)
prob += subject_score * 0.3
return DealResponse.ACCEPTED if random.random() < prob else DealResponse.REJECTED
def _expire_deals(
self,
current_turn: int,
) -> None:
for deal in self._deals.values():
if deal.is_pending(current_turn) is False and deal.response == DealResponse.PENDING:
deal.expire()
def _decay_spite(
self,
current_turn: int,
) -> None:
for seat in self._spite:
self._spite[seat] = [
e for e in self._spite[seat]
if e.expires_turn >= current_turn
]
def _add_spite(
self,
wronged_seat: int,
target_seat: int,
current_turn: int,
intensity: float = 0.6,
) -> None:
self._spite[wronged_seat].append(SpiteEntry(
target_seat=target_seat,
intensity=intensity,
expires_turn=current_turn + SPITE_DECAY_TURNS,
))
@staticmethod
def _generate_flavor(
proposer: int,
deal_type: DealType,
subject_seat: Optional[int],
) -> str:
templates = _PROPOSAL_TEMPLATES.get(deal_type, ["Let's make a deal."])
text = random.choice(templates)
if subject_seat is not None:
text = text.format(target=f"P{subject_seat}")
return text
@staticmethod
def _response_flavor(
responder: int,
deal: Deal,
response: DealResponse,
) -> str:
if response == DealResponse.ACCEPTED:
options = [
"Deal. Don't make me regret this.",
"Fine, I'll hold off.",
"Agreed. For now.",
]
else:
options = [
"No deal. I'll handle things myself.",
"I don't trust you.",
"Pass. You're next anyway.",
]
return random.choice(options)
-259
View File
@@ -1,259 +0,0 @@
"""
Commander AI Lab — Targeting Memory (Phase 6)
==============================================
Per-game record of every targeting decision: who cast spells/abilities
targeting whom, who attacked whom, who blocked whose creatures, and
who broke deals.
The memory feeds threat assessments in ThreatAssessor and politics
decisions in PoliticsEngine, letting AI personas hold grudges,
reciprocate kindness, or shift focus as the game progresses.
Decay model:
Events older than MEMORY_DECAY_TURNS have their weight halved per turn
beyond that threshold, so recent aggression matters more than old actions.
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional
MEMORY_DECAY_TURNS = 4 # turns before weight decay kicks in
DECAY_FACTOR = 0.5 # per-turn weight multiplier after decay threshold
class ActionType(Enum):
TARGETED_SPELL = auto() # single-target spell/ability
ATTACKED = auto() # declared attacker against
BLOCKED = auto() # blocked a creature of
DEALT_COMBAT_DAMAGE = auto() # connected combat damage to
REMOVED_PERMANENT = auto() # destroyed / exiled permanent of
COUNTERED_SPELL = auto() # countered a spell of
STOLE_PERMANENT = auto() # gained control of permanent of
HELPED = auto() # beneficial action toward (negative aggression)
BROKE_DEAL = auto() # violated an accepted deal with
HONORED_DEAL = auto() # complied with an accepted deal with
# Aggression weights per action type (positive = hostile, negative = friendly)
ACTION_WEIGHTS: dict[ActionType, float] = {
ActionType.TARGETED_SPELL: 1.0,
ActionType.ATTACKED: 1.5,
ActionType.BLOCKED: 0.5,
ActionType.DEALT_COMBAT_DAMAGE: 2.0,
ActionType.REMOVED_PERMANENT: 2.5,
ActionType.COUNTERED_SPELL: 2.0,
ActionType.STOLE_PERMANENT: 3.0,
ActionType.HELPED: -1.5,
ActionType.BROKE_DEAL: 4.0,
ActionType.HONORED_DEAL: -1.0,
}
@dataclass
class MemoryEvent:
turn: int
actor: int # seat doing the action
target: int # seat being acted upon
action_type: ActionType
detail: str = "" # e.g. card name
weight: float = 1.0 # base weight (modified by decay at query time)
class TargetingMemory:
"""
Tracks all inter-player targeting events for the duration of a game.
Usage::
mem = TargetingMemory(num_players=4)
mem.record(turn=3, actor=1, target=0,
action_type=ActionType.ATTACKED, detail="declared attacker")
score = mem.aggression_score(actor=1, target=0, current_turn=5)
ranking = mem.most_aggressive_toward(viewer=0, current_turn=5)
"""
def __init__(self, num_players: int = 4) -> None:
self.num_players = num_players
self._events: list[MemoryEvent] = []
# Pre-computed cache (invalidated on each record())
self._cache: dict[tuple, float] = {}
self._cache_turn: int = -1
# ------------------------------------------------------------------
# Recording
# ------------------------------------------------------------------
def record(
self,
turn: int,
actor: int,
target: int,
action_type: ActionType,
detail: str = "",
weight_multiplier: float = 1.0,
) -> None:
"""
Record a new targeting event.
Args:
weight_multiplier: Extra multiplier (e.g. 2.0 for a Wrath effect
that hits everyone).
"""
base_w = ACTION_WEIGHTS.get(action_type, 1.0) * weight_multiplier
self._events.append(MemoryEvent(
turn=turn,
actor=actor,
target=target,
action_type=action_type,
detail=detail,
weight=base_w,
))
self._cache_turn = -1 # invalidate cache
def record_attack(self, turn: int, attacker: int, defender: int) -> None:
self.record(turn, attacker, defender, ActionType.ATTACKED)
def record_damage(self, turn: int, dealer: int, receiver: int, amount: int) -> None:
self.record(turn, dealer, receiver, ActionType.DEALT_COMBAT_DAMAGE,
detail=f"{amount} damage", weight_multiplier=min(amount / 5.0, 3.0))
def record_deal_broken(self, turn: int, breaker: int, victim: int) -> None:
self.record(turn, breaker, victim, ActionType.BROKE_DEAL)
def record_help(self, turn: int, helper: int, beneficiary: int) -> None:
self.record(turn, helper, beneficiary, ActionType.HELPED)
# ------------------------------------------------------------------
# Querying
# ------------------------------------------------------------------
def aggression_score(
self,
actor: int,
target: int,
current_turn: int,
) -> float:
"""
Return the total weighted aggression score from actor toward target.
Higher = more hostile. Can be negative if actor has been very helpful.
Applies temporal decay for events older than MEMORY_DECAY_TURNS.
"""
total = 0.0
for ev in self._events:
if ev.actor != actor or ev.target != target:
continue
age = current_turn - ev.turn
decay = 1.0 if age <= MEMORY_DECAY_TURNS else (
DECAY_FACTOR ** (age - MEMORY_DECAY_TURNS)
)
total += ev.weight * decay
return round(total, 3)
def mutual_aggression(
self,
seat_a: int,
seat_b: int,
current_turn: int,
) -> float:
"""Combined aggression in both directions between two seats."""
return (
self.aggression_score(seat_a, seat_b, current_turn)
+ self.aggression_score(seat_b, seat_a, current_turn)
)
def most_aggressive_toward(
self,
viewer: int,
current_turn: int,
) -> list[tuple[int, float]]:
"""
Return seats ranked by their aggression toward `viewer`, descending.
Returns list of (seat, score) excluding viewer's own seat.
"""
scores = [
(s, self.aggression_score(s, viewer, current_turn))
for s in range(self.num_players)
if s != viewer
]
return sorted(scores, key=lambda x: x[1], reverse=True)
def biggest_threat_overall(
self,
viewer: int,
current_turn: int,
) -> Optional[int]:
"""
Return the seat that has been most aggressive toward `viewer`.
Returns None if no events recorded yet.
"""
ranking = self.most_aggressive_toward(viewer, current_turn)
if not ranking:
return None
seat, score = ranking[0]
return seat if score > 0 else None
def recent_actions(
self,
last_n_turns: int,
current_turn: int,
actor: Optional[int] = None,
target: Optional[int] = None,
) -> list[MemoryEvent]:
"""
Return events from the last N turns, optionally filtered.
"""
cutoff = current_turn - last_n_turns
return [
ev for ev in self._events
if ev.turn >= cutoff
and (actor is None or ev.actor == actor)
and (target is None or ev.target == target)
]
def action_counts(
self,
actor: int,
target: int,
) -> dict[ActionType, int]:
"""Count each action type from actor toward target."""
counts: dict[ActionType, int] = defaultdict(int)
for ev in self._events:
if ev.actor == actor and ev.target == target:
counts[ev.action_type] += 1
return dict(counts)
def total_damage_dealt(
self,
dealer: int,
receiver: int,
) -> float:
"""Sum weight-scaled damage events from dealer to receiver."""
return sum(
ev.weight
for ev in self._events
if ev.actor == dealer
and ev.target == receiver
and ev.action_type == ActionType.DEALT_COMBAT_DAMAGE
)
def summary(
self,
current_turn: int,
) -> dict[tuple[int, int], float]:
"""
Return a full NxN aggression matrix as {(actor, target): score}.
"""
result = {}
for a in range(self.num_players):
for t in range(self.num_players):
if a == t:
continue
s = self.aggression_score(a, t, current_turn)
if s != 0:
result[(a, t)] = s
return result
-279
View File
@@ -1,279 +0,0 @@
"""
Commander AI Lab — Prompt Builder (Phase 1)
============================================
Converts a CommanderGameState into a compact, token-efficient LLM prompt.
Also builds the legal moves prompt for AI action selection.
Design goals:
- Stay under 3000 tokens for the combined system + state + moves prompt
- Be human-readable so the LLM can reason about it naturally
- Abbreviate zones aggressively (counts > full lists where possible)
Usage:
from commander_ai_lab.sim.prompt_builder import state_to_prompt, legal_moves_to_prompt
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from commander_ai_lab.sim.game_state import CommanderGameState
# ── Phase display names ───────────────────────────────────────
PHASE_LABELS: dict[str, str] = {
"untap": "Untap",
"upkeep": "Upkeep",
"draw": "Draw",
"main1": "Main Phase 1",
"combat_begin": "Begin Combat",
"combat_declare_attackers": "Declare Attackers",
"combat_declare_blockers": "Declare Blockers",
"combat_damage": "Combat Damage",
"combat_end": "End of Combat",
"main2": "Main Phase 2",
"end": "End Step",
"cleanup": "Cleanup",
}
def _abbreviate_card_list(cards: list, max_show: int = 6) -> str:
"""Return abbreviated card list string, truncating if too long."""
if not cards:
return "(none)"
names = [getattr(c, 'name', str(c)) for c in cards]
if len(names) <= max_show:
return ", ".join(names)
shown = ", ".join(names[:max_show])
return f"{shown} ... +{len(names) - max_show} more"
def _format_battlefield(cards: list) -> str:
"""Format battlefield cards with tapped state and P/T."""
if not cards:
return "(empty)"
parts = []
for c in cards:
name = getattr(c, 'name', '?')
tapped = " [T]" if getattr(c, 'tapped', False) else ""
type_line = getattr(c, 'type_line', '')
if 'creature' in type_line.lower():
power = getattr(c, 'power', '') or '?'
toughness = getattr(c, 'toughness', '') or '?'
parts.append(f"{name} {power}/{toughness}{tapped}")
else:
parts.append(f"{name}{tapped}")
return ", ".join(parts)
def _format_mana_pool(mana_pool) -> str:
"""Format mana pool as compact string like {W}{W}{U}{C}."""
if mana_pool.total() == 0:
return "(empty)"
parts = []
for color in ['W', 'U', 'B', 'R', 'G', 'C']:
count = getattr(mana_pool, color, 0)
if count > 0:
parts.extend([f"{{{color}}}"] * count)
return "".join(parts)
def state_to_prompt(gs: "CommanderGameState", viewer_seat: int) -> str:
"""
Convert a CommanderGameState into a compact LLM-readable string.
Args:
gs: The current game state.
viewer_seat: The seat index of the AI making the decision
(their hand is shown in full; opponents' hands are hidden).
Returns:
A multi-line string summarizing the game state for the LLM.
"""
lines: list[str] = []
phase_label = PHASE_LABELS.get(gs.current_phase, gs.current_phase)
active = gs.active_player()
active_name = active.name if active else f"Player {gs.active_player_seat}"
priority_p = gs.priority_player()
priority_name = priority_p.name if priority_p else f"Player {gs.priority_seat}"
lines.append("=== COMMANDER GAME STATE ===")
lines.append(f"Turn: {gs.turn} | Phase: {phase_label}")
lines.append(f"Active Player: {active_name} | Priority: {priority_name}")
lines.append("")
# ── Stack ─────────────────────────────────────────────────
if gs.stack:
lines.append(f"STACK ({len(gs.stack)} item{'s' if len(gs.stack) != 1 else ''}):")
for i, item in enumerate(reversed(gs.stack)): # top first
lines.append(f" [{i+1}] {item.description or item.card_name} "
f"(by {gs.commander_players[item.controller_seat].name \
if item.controller_seat < len(gs.commander_players) \
else 'unknown'})")
lines.append("")
# ── Players ───────────────────────────────────────────────
for i, cp in enumerate(gs.commander_players):
is_viewer = (i == viewer_seat)
status = "[YOU]" if is_viewer else ("[ELIM]" if cp.eliminated else "")
bf = gs.sim_state.get_battlefield(i)
lines.append(f"--- Player {i}: {cp.name} {status} ---")
lines.append(f" Life: {cp.life} | Library: {len(cp.library)} cards")
# Commander zone
if cp.commander_zone:
tax_str = f" (tax: +{cp.commander_tax()}" + " mana)" if cp.commander_tax() > 0 else ""
lines.append(f" Commander Zone: {_abbreviate_card_list(cp.commander_zone)}{tax_str}")
else:
lines.append(" Commander Zone: (on battlefield or graveyard)")
# Commander damage received
if cp.commander_damage_received:
dmg_parts = []
for from_seat, dmg in cp.commander_damage_received.items():
if dmg > 0:
from_name = (gs.commander_players[from_seat].name
if from_seat < len(gs.commander_players) else f"P{from_seat}")
dmg_parts.append(f"{from_name}: {dmg}")
if dmg_parts:
lines.append(f" Commander Damage Received: {', '.join(dmg_parts)}")
# Hand — full for viewer, count for opponents
if is_viewer:
lines.append(f" Hand ({len(cp.hand)}): {_abbreviate_card_list(cp.hand, max_show=10)}")
lines.append(f" Mana Pool: {_format_mana_pool(cp.mana_pool)}")
if not gs.land_drop_used and i == gs.active_player_seat:
lines.append(" Land Drop: AVAILABLE this turn")
else:
lines.append(f" Hand: {len(cp.hand)} cards (hidden)")
# Battlefield
lines.append(f" Battlefield: {_format_battlefield(bf)}")
# Graveyard (abbreviated)
if cp.graveyard:
lines.append(f" Graveyard ({len(cp.graveyard)}): "
f"{_abbreviate_card_list(cp.graveyard, max_show=4)}")
# Exile (abbreviated)
if cp.exile:
lines.append(f" Exile ({len(cp.exile)}): "
f"{_abbreviate_card_list(cp.exile, max_show=3)}")
lines.append("")
lines.append("=" * 40)
return "\n".join(lines)
# ── Legal Move Categories ─────────────────────────────────────
LEGAL_MOVE_CATEGORIES = [
"play_land",
"cast_spell",
"cast_commander",
"activate_ability",
"attack",
"block",
"pass_priority",
"pass_turn",
"respond",
"other",
]
def _category_label(category: str) -> str:
return {
"play_land": "Play Land",
"cast_spell": "Cast Spell",
"cast_commander": "Cast Commander",
"activate_ability": "Activate Ability",
"attack": "Declare Attackers",
"block": "Declare Blockers",
"pass_priority": "Pass Priority",
"pass_turn": "Pass Turn / End Phase",
"respond": "Respond (Instant/Ability)",
"other": "Other Action",
}.get(category, category)
def legal_moves_to_prompt(moves: list[dict]) -> str:
"""
Convert a list of legal move dicts into a numbered, categorized prompt string.
Each move dict should have:
{
"id": int, # unique index for LLM selection
"category": str, # from LEGAL_MOVE_CATEGORIES
"description": str, # human-readable action description
"card_name": str (optional), # card involved, if any
"targets": list (optional), # target descriptions
}
Returns:
A numbered list string for inclusion in the LLM prompt.
"""
if not moves:
return "LEGAL ACTIONS:\n (none — you must pass priority)"
lines = ["LEGAL ACTIONS (reply with the number of your choice):"]
# Group by category
by_category: dict[str, list[dict]] = {}
for move in moves:
cat = move.get("category", "other")
by_category.setdefault(cat, []).append(move)
# Output in logical order
for cat in LEGAL_MOVE_CATEGORIES:
if cat not in by_category:
continue
lines.append(f"\n [{_category_label(cat)}]")
for move in by_category[cat]:
move_id = move.get("id", "?")
desc = move.get("description", "(no description)")
targets = move.get("targets", [])
target_str = f" → targets: {', '.join(str(t) for t in targets)}" if targets else ""
lines.append(f" {move_id}. {desc}{target_str}")
return "\n".join(lines)
def build_full_prompt(
gs: "CommanderGameState",
viewer_seat: int,
moves: list[dict],
personality: str = "",
) -> str:
"""
Build the complete prompt to send to the LLM.
Args:
gs: Current game state.
viewer_seat: AI player's seat index.
moves: List of legal move dicts.
personality: Optional personality/system prompt prefix.
Returns:
Full prompt string ready for LLM inference.
"""
sections = []
if personality:
sections.append(personality.strip())
sections.append("")
sections.append(state_to_prompt(gs, viewer_seat))
sections.append("")
sections.append(legal_moves_to_prompt(moves))
sections.append("")
sections.append(
"Respond with ONLY the number of the action you choose. "
"Do not explain your reasoning. Just the number."
)
return "\n".join(sections)
+22 -123
View File
@@ -4,15 +4,12 @@ Commander AI Lab — Simulator Rules & Helpers
AI weights, card enrichment heuristics, and scoring functions.
Ported from mtg-commander-lan JavaScript (AI_DEFAULT_WEIGHTS,
enrichCardForSim, headlessScoreCard, headlessGetPower/Toughness/HasKw).
N-player ready: score_card() accepts num_opponents so keyword weights
scale with pod size (e.g. Propaganda is worth more vs 3 opponents).
"""
from __future__ import annotations
import hashlib
import re
import random
from typing import Optional
from commander_ai_lab.sim.models import Card
@@ -133,12 +130,9 @@ def enrich_card(card: Card) -> Card:
_apply_oracle_flags(card)
return card
# Default: treat as creature with deterministic CMC based on
# card name hash (fixes #32 — no more random.randint jitter
# between games for the same card).
# Default: treat as creature with random CMC
if not card.type_line or len(card.type_line) < 3:
name_hash = int(hashlib.md5(name_lower.encode()).hexdigest(), 16)
estimated_cmc = (name_hash % 5) + 2 # 2-6 inclusive
estimated_cmc = random.randint(2, 6)
estimated_pow = max(1, estimated_cmc - 1)
card.type_line = "Creature"
card.cmc = estimated_cmc
@@ -156,27 +150,24 @@ def _apply_oracle_flags(card: Card) -> None:
oracle = (card.oracle_text or "").lower()
type_line = (card.type_line or "").lower()
# Board wipes (skip if already set)
if not card.is_board_wipe:
# Skip if flags are already explicitly set
if not card.is_removal and not card.is_board_wipe and not card.is_ramp:
# Board wipes
if any(phrase in oracle for phrase in [
"destroy all creature", "destroy all permanent",
"all creatures get -", "exile all creature",
"each creature gets -", "deals 13 damage to each creature",
]):
card.is_board_wipe = True
# Removal (skip if already set)
if not card.is_removal:
if any(phrase in oracle for phrase in [
# Removal
elif any(phrase in oracle for phrase in [
"destroy target", "exile target creature", "exile target permanent",
"deals damage to target", "target creature gets -",
"counter target spell", "return target",
]):
card.is_removal = True
# Bug 17 fix: evaluate ramp independently — cards can be both removal and ramp
# (e.g. Nature's Claim, Beast Within, Abrupt Decay)
if not card.is_ramp:
# Ramp
if any(phrase in oracle for phrase in [
"search your library for a basic land",
"search your library for a land",
@@ -190,46 +181,15 @@ def _apply_oracle_flags(card: Card) -> None:
card.is_ramp = True
# ── Multi-opponent scaling factors ────────────────────────────
# Keywords / effects that become more valuable in larger pods.
# Each entry maps a weight key → the extra bonus *per additional
# opponent* beyond 1. 1v1 uses base weights; 4-player FFA adds
# 3× the bonus for each matching keyword.
_MULTI_OPP_SCALING: dict[str, float] = {
# Group-slug / pillowfort effects punish each opponent
"kw_menace": 0.5, # harder to block with more players
"kw_flying": 0.3, # evasion matters more in FFA
"kw_hexproof": 0.5, # more removal flying around
"kw_indestructible": 0.5,
"kw_ward": 0.4,
# Multiplayer-relevant triggered abilities
"trig_etb": 0.3,
"trig_dies": 0.5, # more creatures dying in FFA
# Spells that scale with opponents
"spell_destroy": 0.5, # removal more precious
"spell_draw": 0.3, # card advantage matters more
"spell_counter": 0.5, # counters protect from more threats
}
def score_card(
card: Card,
weights: Optional[dict] = None,
num_opponents: int = 1,
) -> float:
def score_card(card: Card, weights: Optional[dict] = None) -> float:
"""
Score a card for AI play priority.
Ported from headlessScoreCard() in app.js. The optional
*num_opponents* parameter (default 1 for 1v1) scales keywords
that become more valuable in larger pods.
Ported from headlessScoreCard() in app.js.
"""
w = weights or AI_DEFAULT_WEIGHTS
extra_opps = max(0, num_opponents - 1) # 0 for 1v1
cmc = card.cmc or 0
score = 10.0 - cmc * w.get("card_baseCmcPenalty", 1.0)
# ── Power / toughness bonus ──
if card.pt:
parts = card.pt.split("/")
try:
@@ -242,83 +202,22 @@ def score_card(
t = 0
score += (p + t) * w.get("card_ptBonus", 1.0)
# ── Keyword / oracle-text scoring ──
text = (card.oracle_text or "").lower()
kw_list = [k.lower() for k in (card.keywords or [])]
if "flying" in text:
score += w.get("kw_flying", 2.5)
if "deathtouch" in text:
score += w.get("kw_deathtouch", 2.5)
if "haste" in text:
score += w.get("kw_haste", 2.5)
if "trample" in text:
score += w.get("kw_trample", 1.5)
if "lifelink" in text:
score += w.get("kw_lifelink", 1.5)
def _kw_bonus(keyword: str, weight_key: str, default: float) -> float:
"""Return base weight + per-extra-opponent scaling."""
base = w.get(weight_key, default)
multi = _MULTI_OPP_SCALING.get(weight_key, 0.0) * extra_opps
return base + multi
if "flying" in text or "flying" in kw_list:
score += _kw_bonus("flying", "kw_flying", 2.5)
if "deathtouch" in text or "deathtouch" in kw_list:
score += _kw_bonus("deathtouch", "kw_deathtouch", 2.5)
if "haste" in text or "haste" in kw_list:
score += _kw_bonus("haste", "kw_haste", 2.5)
if "trample" in text or "trample" in kw_list:
score += _kw_bonus("trample", "kw_trample", 1.5)
if "lifelink" in text or "lifelink" in kw_list:
score += _kw_bonus("lifelink", "kw_lifelink", 1.5)
if "double strike" in text or "double strike" in kw_list:
score += _kw_bonus("double strike", "kw_doubleStrike", 3.0)
if "first strike" in text or "first strike" in kw_list:
score += _kw_bonus("first strike", "kw_firstStrike", 1.5)
if "hexproof" in text or "hexproof" in kw_list:
score += _kw_bonus("hexproof", "kw_hexproof", 2.0)
if "indestructible" in text or "indestructible" in kw_list:
score += _kw_bonus("indestructible", "kw_indestructible", 3.0)
if "menace" in text or "menace" in kw_list:
score += _kw_bonus("menace", "kw_menace", 1.5)
if "vigilance" in text or "vigilance" in kw_list:
score += w.get("kw_vigilance", 1.0)
if "ward" in text or "ward" in kw_list:
score += _kw_bonus("ward", "kw_ward", 1.5)
if "infect" in text or "infect" in kw_list:
score += w.get("kw_infect", 3.0)
if "annihilator" in text:
score += w.get("kw_annihilator", 4.0)
if "cascade" in text or "cascade" in kw_list:
score += w.get("kw_cascade", 3.0)
if "defender" in text or "defender" in kw_list:
score += w.get("kw_defender", -2.0)
# ── Triggered ability bonuses ──
if "when" in text and "enters" in text:
score += w.get("trig_etb", 2.0) + _MULTI_OPP_SCALING.get("trig_etb", 0.0) * extra_opps
if "whenever" in text and "attack" in text:
score += w.get("trig_attack", 1.5)
if "when" in text and "dies" in text:
score += w.get("trig_dies", 1.0) + _MULTI_OPP_SCALING.get("trig_dies", 0.0) * extra_opps
# ── Multiplayer-specific oracle patterns ──
if extra_opps > 0:
# "each opponent" effects scale linearly with opponent count
if "each opponent" in text:
score += 2.0 * extra_opps
# Pillowfort (Propaganda, Ghostly Prison, etc.)
if "attack you" in text and ("pay" in text or "tax" in text or "can't" in text):
score += 1.5 * extra_opps
# "each player" draw/damage effects
if "each player" in text:
score += 1.0 * extra_opps
# ── Spell-type bonuses ──
if card.is_ramp:
score += w.get("spell_ramp", 6.0)
if card.is_removal:
score += _kw_bonus("removal", "spell_destroy", 5.0)
if card.is_board_wipe:
# Board wipes get better in multiplayer — more creatures to hit
score += 1.0 * extra_opps
if "draw" in text and ("card" in text or "cards" in text):
score += w.get("spell_draw", 3.0) + _MULTI_OPP_SCALING.get("spell_draw", 0.0) * extra_opps
if "counter target" in text:
score += w.get("spell_counter", 4.0) + _MULTI_OPP_SCALING.get("spell_counter", 0.0) * extra_opps
if "search your library" in text:
score += w.get("spell_search", 2.0)
score += w.get("spell_destroy", 5.0)
return score
-143
View File
@@ -1,143 +0,0 @@
"""
Commander AI Lab — State Snapshot Logger (Phase 1)
===================================================
Saves game state JSON + LLM prompt string per AI decision for debugging
and AI quality analysis. Rotates to a maximum of MAX_SNAPSHOTS files.
Snapshot files are saved to: logs/snapshots/
Filename format: snapshot_YYYYMMDD_HHMMSS_<turn>_<seat>.json
Usage:
from commander_ai_lab.sim.state_logger import StateLogger
logger = StateLogger() # uses default logs/snapshots/
logger = StateLogger(log_dir="my/path") # custom directory
logger.save(game_state, prompt, chosen_move_id, seat)
logger.list_snapshots() # returns list of file paths
logger.clear_all() # delete all snapshots
"""
from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from commander_ai_lab.sim.game_state import CommanderGameState
MAX_SNAPSHOTS = 100
DEFAULT_LOG_DIR = Path("logs") / "snapshots"
class StateLogger:
"""
Rotating snapshot logger for AI decision states.
Keeps at most MAX_SNAPSHOTS files in the log directory.
When the limit is reached, the oldest snapshot is deleted
before writing the new one (FIFO rotation).
"""
def __init__(self, log_dir: Optional[str | Path] = None) -> None:
self.log_dir = Path(log_dir) if log_dir else DEFAULT_LOG_DIR
self.log_dir.mkdir(parents=True, exist_ok=True)
# ── Public API ────────────────────────────────────────────
def save(
self,
game_state: "CommanderGameState",
prompt: str,
chosen_move_id: Optional[int],
seat: int,
fallback_used: bool = False,
extra: Optional[dict] = None,
) -> Path:
"""
Save a snapshot of the game state + prompt used for an AI decision.
Args:
game_state: The CommanderGameState at decision time.
prompt: The full prompt string sent to the LLM.
chosen_move_id: The move ID the LLM (or fallback) selected.
seat: The AI player's seat index.
fallback_used: True if the fallback heuristic was used instead of LLM.
extra: Optional dict of extra metadata to include.
Returns:
Path to the saved snapshot file.
"""
self._rotate()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"snapshot_{timestamp}_t{game_state.turn}_s{seat}.json"
filepath = self.log_dir / filename
snapshot = {
"meta": {
"timestamp": datetime.now().isoformat(),
"turn": game_state.turn,
"phase": game_state.current_phase,
"activePlayerSeat": game_state.active_player_seat,
"decisionBySeat": seat,
"chosenMoveId": chosen_move_id,
"fallbackUsed": fallback_used,
},
"gameState": game_state.to_dict(),
"prompt": prompt,
}
if extra:
snapshot["extra"] = extra
with open(filepath, "w", encoding="utf-8") as f:
json.dump(snapshot, f, indent=2, ensure_ascii=False)
return filepath
def list_snapshots(self) -> list[Path]:
"""Return all snapshot files sorted by creation time (oldest first)."""
files = sorted(
self.log_dir.glob("snapshot_*.json"),
key=lambda p: p.stat().st_ctime,
)
return files
def count(self) -> int:
"""Return the number of snapshot files currently stored."""
return len(self.list_snapshots())
def clear_all(self) -> int:
"""Delete all snapshot files. Returns the number of files deleted."""
files = self.list_snapshots()
for f in files:
f.unlink(missing_ok=True)
return len(files)
def load(self, path: Path) -> dict:
"""Load and return a snapshot dict from a file path."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def load_latest(self) -> Optional[dict]:
"""Load and return the most recent snapshot, or None if empty."""
files = self.list_snapshots()
if not files:
return None
return self.load(files[-1])
# ── Internal ──────────────────────────────────────────────
def _rotate(self) -> None:
"""
If at MAX_SNAPSHOTS, delete the oldest file before writing a new one.
Ensures the log directory never exceeds MAX_SNAPSHOTS files.
"""
files = self.list_snapshots()
while len(files) >= MAX_SNAPSHOTS:
oldest = files.pop(0)
oldest.unlink(missing_ok=True)
-164
View File
@@ -1,164 +0,0 @@
"""
Commander AI Lab — Threat Assessor (Phase 3)
=============================================
Scores every seat's threat level so the turn manager and AI opponents can
make targeting / blocking decisions.
Threat score is a weighted sum of:
- Board power (total creature power)
- Board size (non-land permanents)
- Hand size (unknown cards = potential)
- Life total proximity to win (40 is baseline)
- Combo proximity (commanders in play, key enchantments, etc.)
Scores are normalized to [0.0, 1.0]. Higher = more dangerous.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Optional
from commander_ai_lab.sim.game_state import CommanderGameState
@dataclass
class ThreatScore:
seat: int
name: str
total: float # Normalized [0.0, 1.0]
board_power: float
board_size: float
hand_potential: float
life_threat: float # How much damage they could deal relative to opponents' life
combo_proximity: float
raw: dict = field(default_factory=dict) # Raw sub-scores before normalization
def __repr__(self) -> str:
return (
f"ThreatScore(seat={self.seat} name={self.name!r} "
f"total={self.total:.3f} board_power={self.board_power:.3f} "
f"life_threat={self.life_threat:.3f})"
)
# Weight config — tune these without changing logic
_W_BOARD_POWER = 0.35
_W_BOARD_SIZE = 0.20
_W_HAND = 0.15
_W_LIFE_THREAT = 0.20
_W_COMBO = 0.10
# Combo-indicator keywords / oracle phrases
_COMBO_KEYWORDS = [
"infinite", "you win the game", "extra turn", "search your library",
"whenever .* enters", "double", "each opponent loses",
]
def assess_threats(
game_state: CommanderGameState,
viewer_seat: int,
) -> list[ThreatScore]:
"""
Return a list of ThreatScore for every non-eliminated seat, ordered by
descending threat. The viewer's own seat is included but marked.
Args:
game_state: Current CommanderGameState.
viewer_seat: The AI seat requesting the assessment (used for relative
life threat calculation).
Returns:
List[ThreatScore] sorted by total descending.
"""
scores: list[ThreatScore] = []
# Bug 12 fix: game_state.players now works via the @property added in game_state.py
players = game_state.players
viewer = players[viewer_seat] if viewer_seat < len(players) else None
viewer_life = viewer.life if viewer else 40
for seat, player in enumerate(players):
if player.eliminated:
continue
# --- Board power ---
# Bug 12/13 fix: use game_state.battlefield(seat) instead of player.battlefield,
# and use Card dataclass attribute access instead of dict .get()
total_power = 0
board_size = 0
combo_score = 0
for c in game_state.battlefield(seat):
if c.is_creature():
total_power += c.get_power()
if not c.is_land():
board_size += 1
# Combo proximity: scan oracle text for combo indicators
# Bug 14 fix: import re moved to top of file
oracle = (c.oracle_text or "").lower()
for kw in _COMBO_KEYWORDS:
if re.search(kw, oracle):
combo_score += 1
break
hand_size = len(player.hand)
# Normalize sub-scores to [0, 1]
bp_norm = min(total_power / 20.0, 1.0)
bs_norm = min(board_size / 10.0, 1.0)
hand_norm = min(hand_size / 10.0, 1.0)
combo_norm = min(combo_score / 5.0, 1.0)
# Life threat: how close are they to killing the viewer?
# If their total power >= viewer's life → maximum threat
life_threat_raw = total_power / max(viewer_life, 1)
lt_norm = min(life_threat_raw, 1.0)
total = (
_W_BOARD_POWER * bp_norm
+ _W_BOARD_SIZE * bs_norm
+ _W_HAND * hand_norm
+ _W_LIFE_THREAT * lt_norm
+ _W_COMBO * combo_norm
)
scores.append(ThreatScore(
seat=seat,
name=player.name,
total=round(total, 4),
board_power=round(bp_norm, 4),
board_size=round(bs_norm, 4),
hand_potential=round(hand_norm, 4),
life_threat=round(lt_norm, 4),
combo_proximity=round(combo_norm, 4),
raw={
"total_power": total_power,
"board_non_land": board_size,
"hand_size": hand_size,
"combo_indicators": combo_score,
"life": player.life,
},
))
scores.sort(key=lambda s: -s.total)
return scores
def top_threat(
game_state: CommanderGameState,
viewer_seat: int,
exclude_self: bool = True,
) -> Optional[ThreatScore]:
"""
Return the single highest-threat opponent.
Args:
exclude_self: If True (default), skip viewer_seat.
"""
threats = assess_threats(game_state, viewer_seat)
for t in threats:
if exclude_self and t.seat == viewer_seat:
continue
return t
return None
-624
View File
@@ -1,624 +0,0 @@
"""
Commander AI Lab — Commander Turn Manager (Phase 3)
====================================================
Asynchronous multi-player turn queue with full Commander structure.
Features:
- Variable player count (2–4 seats), any mix of human / AIOpponent
- Human seats supported: decide_action() is skipped; caller injects the
move via human_play(move_id) instead
- Correct phase ordering: Untap → Upkeep → Draw → Main1 → Combat
→ Main2 → End Step
- Priority-passing: each phase dispatches to priority_seat and cycles
through APNAP order until all players pass in sequence
- AI decisions run in a thread pool so the event loop stays responsive;
UI can display the "thinking" status via on_thinking callback
- Player elimination mid-game: eliminated players are removed from the
turn queue immediately after any damage is dealt
- Per-turn threat re-evaluation at the start of each AI main phase
- All game events surfaced through optional async callbacks
Usage (AI-only game)::
from commander_ai_lab.sim.turn_manager import CommanderTurnManager
from commander_ai_lab.sim.ai_opponent import create_four_player_ai_roster
from commander_ai_lab.sim.game_state import CommanderGameState
gs = CommanderGameState(...) # build your initial state
ai_list = create_four_player_ai_roster() # Phase 2 factory
tm = CommanderTurnManager(game_state=gs, ai_opponents=ai_list)
asyncio.run(tm.run_game())
Usage with a human player at seat 0::
tm = CommanderTurnManager(
game_state=gs,
ai_opponents=ai_list,
human_seats={0},
on_action=my_ui_callback,
)
# In your UI event handler:
await tm.human_play(move_id=chosen_id)
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Awaitable, Callable, Optional, Set
from commander_ai_lab.sim.game_state import CommanderGameState, CommanderPlayer
from commander_ai_lab.sim.ai_opponent import AIOpponent
from commander_ai_lab.sim.threat_assessor import assess_threats, ThreatScore
logger = logging.getLogger("turn_manager")
# Commander turn phases in order
PHASES = [
"untap",
"upkeep",
"draw",
"main1",
"begin_combat",
"declare_attackers",
"declare_blockers",
"combat_damage",
"end_combat",
"main2",
"end_step",
"cleanup",
]
# Phases where a player can take an action (play land, cast spell, etc.)
ACTION_PHASES = {"main1", "main2"}
# Phases where instant-speed responses can happen
RESPONSE_PHASES = {"upkeep", "begin_combat", "end_combat", "end_step"}
@dataclass
class GameEvent:
"""A single event emitted by the turn manager."""
event_type: str # 'phase_change', 'action', 'narration', 'thinking', 'elimination', 'game_over'
seat: int
player_name: str
phase: str
turn: int
move_id: Optional[int] = None
move_description: str = ""
narration: str = ""
extra: dict = field(default_factory=dict)
@dataclass
class TurnManagerConfig:
"""Tunable parameters for the turn manager."""
max_turns: int = 40
ai_decision_timeout: float = 30.0 # seconds before fallback
ai_narration_enabled: bool = True
ai_decision_delay: float = 0.0 # cosmetic pause (set > 0 for UI readability)
priority_pass_limit: int = 8 # max APNAP passes per phase before auto-advance
thread_pool_size: int = 3 # concurrent AI threads
class CommanderTurnManager:
"""
Async Commander turn manager.
Drives the game loop, dispatching each seat's turn through the correct
Commander phases, running AI decisions off-thread, and surfacing all
events via optional callbacks.
Parameters
----------
game_state : CommanderGameState
Mutable game state that this manager operates on.
ai_opponents : list[AIOpponent]
One AIOpponent per AI seat. Must have seat attribute matching
game_state.players index. Human seats are gaps in this list.
human_seats : set[int]
Seats controlled by human players. Turns for these seats block
until human_play() is called.
on_event : async callable, optional
Async callback invoked for every GameEvent (UI integration hook).
on_thinking : async callable, optional
Async callback invoked when AI starts / finishes thinking.
Signature: on_thinking(seat, is_thinking: bool)
config : TurnManagerConfig
"""
def __init__(
self,
game_state: CommanderGameState,
ai_opponents: list[AIOpponent],
human_seats: Optional[Set[int]] = None,
on_event: Optional[Callable[[GameEvent], Awaitable[None]]] = None,
on_thinking: Optional[Callable[[int, bool], Awaitable[None]]] = None,
config: Optional[TurnManagerConfig] = None,
) -> None:
self.gs = game_state
self.config = config or TurnManagerConfig()
self.human_seats: Set[int] = human_seats or set()
self.on_event = on_event
self.on_thinking = on_thinking
# Build seat → AIOpponent map
self._ai_map: dict[int, AIOpponent] = {ai.seat: ai for ai in ai_opponents}
# Human move injection: seat → asyncio.Future
self._human_move_futures: dict[int, asyncio.Future] = {}
# Turn queue: double-ended queue of active seat indices
self._turn_queue: deque[int] = deque(
i for i in range(len(game_state.players))
)
# Thread pool for AI inference (keeps event loop unblocked)
self._executor = ThreadPoolExecutor(
max_workers=self.config.thread_pool_size,
thread_name_prefix="ai_worker",
)
# Stats
self._total_actions: int = 0
self._fallback_count: int = 0
self.game_over: bool = False
self.winner_seat: Optional[int] = None
# Latest threat scores per seat (refreshed each main phase)
self._threat_cache: dict[int, list[ThreatScore]] = {}
# ── Public API ────────────────────────────────────────────────────────────
async def run_game(self) -> Optional[int]:
"""
Run the full game loop until one player wins or max_turns is reached.
Returns the winner's seat index, or None if the game timed out.
"""
logger.info("Game started. Players: %s",
[p.name for p in self.gs.players])
for turn_num in range(1, self.config.max_turns + 1):
self.gs.turn = turn_num
if self.game_over:
break
# Snapshot the active seats at the start of each full round
active_seats = list(self._turn_queue)
for seat in active_seats:
if self.gs.players[seat].eliminated:
continue
self.gs.active_player_seat = seat
await self._run_player_turn(seat, turn_num)
# Check for eliminations after each player's turn
await self._check_eliminations()
if self.game_over:
break
if self.game_over:
break
if not self.game_over:
# Timeout — winner is highest life total
alive = [
(p.life, i)
for i, p in enumerate(self.gs.players)
if not p.eliminated
]
if alive:
_, self.winner_seat = max(alive)
self.game_over = True
await self._emit(GameEvent(
event_type="game_over",
seat=self.winner_seat or 0,
player_name=self.gs.players[self.winner_seat or 0].name,
phase="cleanup",
turn=self.gs.turn,
extra={"reason": "timeout", "winner": self.winner_seat},
))
logger.info("Game over. Winner seat: %s", self.winner_seat)
self._executor.shutdown(wait=False)
return self.winner_seat
async def human_play(self, move_id: int) -> None:
"""
Inject a move from a human player.
Call this from your UI event handler when the human selects an action.
The turn manager is blocking on an asyncio.Future for the active
human seat — this resolves it.
"""
# Find the currently-waiting human seat
active = self.gs.active_player_seat
if active in self._human_move_futures:
fut = self._human_move_futures[active]
if not fut.done():
asyncio.get_running_loop().call_soon_threadsafe(fut.set_result, move_id)
else:
logger.warning("human_play called but future already resolved for seat %d", active)
else:
logger.warning("human_play called but no future pending for seat %d", active)
def get_legal_moves(self, seat: int) -> list[dict]:
"""
Return the current legal moves for a seat.
Delegates to the rules engine on game_state.
"""
return self.gs.get_legal_moves(seat)
def get_threat_scores(self, viewer_seat: int) -> list[ThreatScore]:
"""Return the latest cached threat scores from viewer_seat's perspective."""
return self._threat_cache.get(viewer_seat, [])
def get_stats(self) -> dict:
"""Return turn manager statistics."""
return {
"turn": self.gs.turn,
"total_actions": self._total_actions,
"fallback_count": self._fallback_count,
"game_over": self.game_over,
"winner_seat": self.winner_seat,
"active_seats": list(self._turn_queue),
}
# ── Turn Phases ───────────────────────────────────────────────────────────
async def _run_player_turn(self, seat: int, turn_num: int) -> None:
"""Drive all phases of one player's turn."""
player = self.gs.players[seat]
logger.debug("Turn %d — %s's turn begins", turn_num, player.name)
for phase in PHASES:
if self.game_over:
return
self.gs.current_phase = phase
self.gs.priority_seat = seat
await self._emit(GameEvent(
event_type="phase_change",
seat=seat,
player_name=player.name,
phase=phase,
turn=turn_num,
))
if phase == "untap":
await self._phase_untap(seat)
elif phase == "draw":
await self._phase_draw(seat, turn_num)
elif phase in ACTION_PHASES:
await self._phase_main(seat, turn_num, phase)
elif phase == "declare_attackers":
await self._phase_combat(seat, turn_num)
elif phase == "cleanup":
await self._phase_cleanup(seat)
elif phase in RESPONSE_PHASES:
# APNAP priority window — AI opponents may respond
await self._apnap_priority_window(seat, turn_num, phase)
# ── Individual Phases ─────────────────────────────────────────────────────
async def _phase_untap(self, active_seat: int) -> None:
"""Untap all permanents for active player. No priority."""
player = self.gs.players[active_seat]
for card in self.gs.sim_state.get_battlefield(active_seat):
card.tapped = False
logger.debug(" [untap] %s untapped all permanents", player.name)
async def _phase_draw(self, active_seat: int, turn_num: int) -> None:
"""Active player draws one card (skip on turn 1 seat 0 by default)."""
player = self.gs.players[active_seat]
if turn_num == 1 and active_seat == 0:
return # Traditional no-draw-on-first-turn rule
if player.library:
drawn = player.library.pop()
player.hand.append(drawn)
logger.debug(" [draw] %s drew %s", player.name, drawn.name)
async def _phase_main(self, active_seat: int, turn_num: int, phase: str) -> None:
"""
Main phase: active player takes one action, then APNAP window.
Threat scores re-evaluated at the start of main1.
"""
if phase == "main1":
self._refresh_threat_scores(active_seat)
legal_moves = self.get_legal_moves(active_seat)
if not legal_moves:
return
move_id = await self._get_action(active_seat, legal_moves, turn_num)
if move_id is not None:
desc = self._move_desc(move_id, legal_moves)
self.gs.apply_move(active_seat, move_id)
self._total_actions += 1
narration = ""
if self.config.ai_narration_enabled and active_seat in self._ai_map:
ai = self._ai_map[active_seat]
narration = ai.narrate_play(move_id, legal_moves, self.gs)
await self._emit(GameEvent(
event_type="action",
seat=active_seat,
player_name=self.gs.players[active_seat].name,
phase=phase,
turn=turn_num,
move_id=move_id,
move_description=desc,
narration=narration,
))
# APNAP: other players respond after active player acts
await self._apnap_priority_window(active_seat, turn_num, phase)
async def _phase_combat(self, active_seat: int, turn_num: int) -> None:
"""Declare attackers phase."""
legal_moves = self.get_legal_moves(active_seat)
attack_moves = [m for m in legal_moves if m.get("category") == "attack"]
if not attack_moves:
return
move_id = await self._get_action(active_seat, attack_moves, turn_num)
if move_id is not None:
desc = self._move_desc(move_id, attack_moves)
self.gs.apply_move(active_seat, move_id)
self._total_actions += 1
narration = ""
if self.config.ai_narration_enabled and active_seat in self._ai_map:
narration = self._ai_map[active_seat].narrate_play(
move_id, attack_moves, self.gs
)
await self._emit(GameEvent(
event_type="action",
seat=active_seat,
player_name=self.gs.players[active_seat].name,
phase="declare_attackers",
turn=turn_num,
move_id=move_id,
move_description=desc,
narration=narration,
))
# Defending players get priority to declare blockers
await self._apnap_priority_window(active_seat, turn_num, "declare_blockers")
async def _phase_cleanup(self, active_seat: int) -> None:
"""Discard to hand size (7), remove end-of-turn effects."""
player = self.gs.players[active_seat]
max_hand = 7
while len(player.hand) > max_hand:
discarded = player.hand.pop() # Simplified: discard last
player.graveyard.append(discarded)
logger.debug(
" [cleanup] %s discarded %s",
player.name, discarded.name,
)
# ── Priority Passing (APNAP) ──────────────────────────────────────────────
async def _apnap_priority_window(
self,
active_seat: int,
turn_num: int,
phase: str,
) -> None:
"""
APNAP priority window: Active Player, Non-Active Players in seat order.
Cycles until all players pass consecutively, or pass_limit is reached.
AI players may cast instants / activate abilities.
Human players are skipped (assumed to respond via separate UI flow).
"""
n = len(self.gs.players)
# Build APNAP order: active first, then the rest in seat order
apnap_order = [active_seat] + [
i for i in range(n)
if i != active_seat and not self.gs.players[i].eliminated
]
consecutive_passes = 0
passes_needed = len(apnap_order)
iterations = 0
max_iter = passes_needed * self.config.priority_pass_limit
while consecutive_passes < passes_needed and iterations < max_iter:
for seat in apnap_order:
iterations += 1 # Bug 11 fix: always increment iterations
if self.gs.players[seat].eliminated:
consecutive_passes += 1
continue
# Human seats: auto-pass in APNAP window (they act in main phase)
if seat in self.human_seats:
consecutive_passes += 1
continue
# AI instant-speed response
if seat in self._ai_map:
legal = [
m for m in self.get_legal_moves(seat)
if m.get("category") in ("instant", "activate_ability", "trigger")
]
if legal:
move_id = await self._get_action(seat, legal, turn_num)
if move_id is not None:
self.gs.apply_move(seat, move_id)
self._total_actions += 1
consecutive_passes = 0
await self._emit(GameEvent(
event_type="action",
seat=seat,
player_name=self.gs.players[seat].name,
phase=phase,
turn=turn_num,
move_id=move_id,
move_description=self._move_desc(move_id, legal),
extra={"apnap": True},
))
continue
consecutive_passes += 1
if iterations >= max_iter:
logger.debug("APNAP pass limit reached in phase %s", phase)
break
# ── AI Decision (off-thread) ──────────────────────────────────────────────
async def _get_action(
self,
seat: int,
legal_moves: list[dict],
turn_num: int,
) -> Optional[int]:
"""
Dispatch a decision request to the correct handler:
- Human seat: await the injected future
- AI seat: run decide_action() in thread pool
"""
if seat in self.human_seats:
return await self._await_human_move(seat)
if seat in self._ai_map:
return await self._run_ai_decision(seat, legal_moves)
# Headless seat (no AI assigned) — pass
return None
async def _await_human_move(self, seat: int) -> Optional[int]:
"""Block until human_play() injects a move for this seat."""
loop = asyncio.get_running_loop()
fut: asyncio.Future = loop.create_future()
self._human_move_futures[seat] = fut
try:
move_id = await asyncio.wait_for(fut, timeout=300.0) # 5-min timeout
except asyncio.TimeoutError:
logger.warning("Human seat %d timed out — passing", seat)
move_id = None
finally:
self._human_move_futures.pop(seat, None)
return move_id
async def _run_ai_decision(
self,
seat: int,
legal_moves: list[dict],
) -> Optional[int]:
"""
Run AIOpponent.decide_action() in a thread pool so the event loop
stays responsive during LLM inference.
"""
ai = self._ai_map[seat]
# Signal UI: AI is thinking
if self.on_thinking:
await self.on_thinking(seat, True)
loop = asyncio.get_running_loop()
t_start = time.monotonic()
try:
move_id = await asyncio.wait_for(
loop.run_in_executor(
self._executor,
ai.decide_action,
self.gs,
legal_moves,
),
timeout=self.config.ai_decision_timeout,
)
except asyncio.TimeoutError:
logger.warning(
"[seat %d %s] AI decision timed out after %.1fs — using fallback",
seat, ai.name, self.config.ai_decision_timeout,
)
move_id = ai._heuristic_move(self.gs, legal_moves)
self._fallback_count += 1
finally:
# Signal UI: AI done thinking
if self.on_thinking:
await self.on_thinking(seat, False)
elapsed = time.monotonic() - t_start
logger.debug("[seat %d %s] decided move=%s in %.2fs", seat, ai.name, move_id, elapsed)
# Cosmetic delay so humans can follow the AI's play
if self.config.ai_decision_delay > 0:
await asyncio.sleep(self.config.ai_decision_delay)
return move_id
# ── Eliminations ─────────────────────────────────────────────────────────
async def _check_eliminations(
self,
) -> None:
"""Detect newly eliminated players and remove from turn queue."""
for seat, player in enumerate(self.gs.players):
if player.eliminated and seat in self._turn_queue:
self._turn_queue.remove(seat)
logger.info("%s (seat %d) eliminated", player.name, seat)
await self._emit(GameEvent(
event_type="elimination",
seat=seat,
player_name=player.name,
phase=self.gs.current_phase,
turn=self.gs.turn,
extra={"remaining": list(self._turn_queue)},
))
alive = [i for i in self._turn_queue if not self.gs.players[i].eliminated]
if len(alive) <= 1:
self.game_over = True
self.winner_seat = alive[0] if alive else None
if self.winner_seat is not None:
await self._emit(GameEvent(
event_type="game_over",
seat=self.winner_seat,
player_name=self.gs.players[self.winner_seat].name,
phase=self.gs.current_phase,
turn=self.gs.turn,
extra={"winner": self.winner_seat, "reason": "elimination"},
))
# ── Threat Re-evaluation ──────────────────────────────────────────────────
def _refresh_threat_scores(self, active_seat: int) -> None:
"""Recompute threat scores for all non-eliminated seats."""
for seat in range(len(self.gs.players)):
if not self.gs.players[seat].eliminated:
self._threat_cache[seat] = assess_threats(self.gs, viewer_seat=seat)
logger.debug(
"[seat %d] Threat scores refreshed: %s",
active_seat,
{s: round(ts[0].total, 3) if ts else 0 for s, ts in self._threat_cache.items()},
)
# ── Helpers ───────────────────────────────────────────────────────────────
async def _emit(self, event: GameEvent) -> None:
"""Fire the on_event callback if registered."""
if self.on_event:
try:
await self.on_event(event)
except Exception as exc:
logger.warning("on_event callback raised: %s", exc)
@staticmethod
def _move_desc(move_id: Optional[int], moves: list[dict]) -> str:
for m in moves:
if m["id"] == move_id:
return m.get("description", f"Move {move_id}")
return f"Move {move_id}"
-10
View File
@@ -1,10 +0,0 @@
"""
Commander AI Lab — Web Server Package (Phase 5)
Exposes:
create_app() — FastAPI application factory
run() — convenience entry point (uvicorn)
"""
from commander_ai_lab.web.app import create_app, run
__all__ = ["create_app", "run"]
-102
View File
@@ -1,102 +0,0 @@
"""
Commander AI Lab — FastAPI Application Factory (Phase 5)
=========================================================
Creates and wires the FastAPI application:
- REST API router (/api/v1/...)
- WebSocket router (/ws/game/{game_id})
- Scryfall art proxy (/api/v1/art/{card_name})
- Static file mount (/static -> web/static/)
- CORS middleware for browser dev server
Usage::
uvicorn commander_ai_lab.web.app:app --reload --port 8000
# or programmatically:
from commander_ai_lab.web.app import create_app
app = create_app()
"""
from __future__ import annotations
import os
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from commander_ai_lab.web.routers.api import router as api_router
from commander_ai_lab.web.routers.ws import router as ws_router
from commander_ai_lab.web.routers.art import router as art_router
_STATIC_DIR = Path(__file__).parent / "static"
def create_app(
allow_origins: list[str] | None = None,
static_dir: Path | None = None,
) -> FastAPI:
"""
FastAPI application factory.
Args:
allow_origins: CORS origins. Defaults to ["*"] (dev mode).
static_dir: Path to static files. Defaults to web/static/.
"""
app = FastAPI(
title="Commander AI Lab",
version="0.5.0",
description="4-player Commander AI game server with WebSocket game channel.",
)
# CORS
origins = allow_origins or ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(api_router, prefix="/api/v1")
app.include_router(ws_router) # /ws/game/{game_id}
app.include_router(art_router, prefix="/api/v1")
# Static files (web client)
sd = static_dir or _STATIC_DIR
if sd.exists():
app.mount("/static", StaticFiles(directory=str(sd), html=True), name="static")
@app.get("/health", tags=["meta"])
async def health() -> dict:
return {"status": "ok", "version": "0.5.0"}
return app
# Module-level app instance (for uvicorn)
app = create_app()
def run(
host: str = "0.0.0.0",
port: int = 8000,
reload: bool = False,
) -> None:
"""Convenience entry point."""
try:
import uvicorn
except ImportError:
raise RuntimeError("uvicorn is required: pip install uvicorn[standard]")
uvicorn.run(
"commander_ai_lab.web.app:app",
host=host,
port=port,
reload=reload,
)
if __name__ == "__main__":
run(reload=True)
@@ -1,98 +0,0 @@
"""
Commander AI Lab — WebSocket Connection Manager (Phase 5)
==========================================================
Manages active WebSocket connections grouped by game_id.
Handles:
- connect / disconnect lifecycle
- broadcast to all connections in a game room
- per-seat send (used for private hand data)
"""
from __future__ import annotations
import asyncio
import json
from collections import defaultdict
from typing import Optional
from fastapi import WebSocket
class ConnectionManager:
"""
Thread-safe (asyncio) WebSocket connection registry.
Connections are keyed by game_id. Each connection also records its seat
so the server can send private data (e.g. hand cards) to a specific player.
"""
def __init__(self) -> None:
# game_id -> list of (WebSocket, seat)
self._rooms: dict[str, list[tuple[WebSocket, int]]] = defaultdict(list)
async def connect(
self,
ws: WebSocket,
game_id: str,
seat: int,
) -> None:
"""Accept and register a new WebSocket connection."""
await ws.accept()
self._rooms[game_id].append((ws, seat))
def disconnect(
self,
ws: WebSocket,
game_id: str,
) -> None:
"""Remove a disconnected WebSocket from its room."""
self._rooms[game_id] = [
(w, s) for w, s in self._rooms[game_id] if w is not ws
]
if not self._rooms[game_id]:
del self._rooms[game_id]
async def broadcast(
self,
game_id: str,
message: dict,
) -> None:
"""
Send a JSON message to all connections in a game room.
Dead connections are silently removed.
"""
payload = json.dumps(message)
dead: list[WebSocket] = []
for ws, seat in list(self._rooms.get(game_id, [])):
try:
await ws.send_text(payload)
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(ws, game_id)
async def send_to_seat(
self,
game_id: str,
seat: int,
message: dict,
) -> None:
"""
Send a JSON message only to connections with a matching seat.
Used for private hand updates.
"""
payload = json.dumps(message)
for ws, s in list(self._rooms.get(game_id, [])):
if s == seat:
try:
await ws.send_text(payload)
except Exception:
self.disconnect(ws, game_id)
def connection_count(self, game_id: str) -> int:
return len(self._rooms.get(game_id, []))
def seats_connected(self, game_id: str) -> list[int]:
return [s for _, s in self._rooms.get(game_id, [])]
@@ -1 +0,0 @@
# Router sub-package
-121
View File
@@ -1,121 +0,0 @@
"""
Commander AI Lab — REST API Router (Phase 5)
=============================================
Endpoints:
POST /api/v1/games Create a new game session
GET /api/v1/games/{game_id} Get game state snapshot
POST /api/v1/games/{game_id}/move Submit a human move
GET /api/v1/games/{game_id}/moves List legal moves for human seat
POST /api/v1/games/{game_id}/concede Concede for a seat
DELETE /api/v1/games/{game_id} Tear down a game session
GET /api/v1/games List active sessions
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Body
from pydantic import BaseModel, Field
from commander_ai_lab.web.session_store import SessionStore, GameSession
router = APIRouter(tags=["game"])
_store = SessionStore()
# ---------------------------------------------------------------------------
# Request / Response models
# ---------------------------------------------------------------------------
class CreateGameRequest(BaseModel):
player_names: list[str] = Field(
default=["Human", "AI-Timmy", "AI-Spike", "AI-Johnny"],
description="Exactly 4 player names. Index 0 is always the human seat.",
)
human_seat: int = Field(default=0, ge=0, le=3)
ai_personality: list[str] = Field(
default=["aggressive", "control", "combo"],
description="Personality for seats 1, 2, 3 respectively.",
)
class MoveRequest(BaseModel):
seat: int = Field(..., ge=0, le=3)
move_id: int = Field(..., description="Move ID from /moves endpoint.")
class ConcedeRequest(BaseModel):
seat: int = Field(..., ge=0, le=3)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/games", status_code=201)
async def create_game(req: CreateGameRequest) -> dict:
"""Create and start a new 4-player Commander game session."""
if len(req.player_names) != 4:
raise HTTPException(400, "Exactly 4 player names are required.")
session = await _store.create_session(
player_names=req.player_names,
human_seat=req.human_seat,
ai_personalities=req.ai_personality,
)
return {"game_id": session.game_id, "status": "started"}
@router.get("/games")
async def list_games() -> dict:
"""List all active game session IDs."""
return {"games": _store.list_session_ids()}
@router.get("/games/{game_id}")
async def get_game(game_id: str) -> dict:
"""Return a JSON snapshot of the current game state."""
session = _get_session(game_id)
return session.state_snapshot()
@router.get("/games/{game_id}/moves")
async def get_legal_moves(game_id: str, seat: int = 0) -> dict:
"""Return legal moves for the given seat."""
session = _get_session(game_id)
moves = session.get_legal_moves(seat)
return {"seat": seat, "moves": moves}
@router.post("/games/{game_id}/move")
async def submit_move(game_id: str, req: MoveRequest) -> dict:
"""Submit a human move. Returns updated game state snapshot."""
session = _get_session(game_id)
ok = await session.apply_human_move(seat=req.seat, move_id=req.move_id)
if not ok:
raise HTTPException(400, "Move is not legal or it is not your turn.")
return {"accepted": True, "state": session.state_snapshot()}
@router.post("/games/{game_id}/concede")
async def concede(game_id: str, req: ConcedeRequest) -> dict:
"""Concede the game for the given seat."""
session = _get_session(game_id)
await session.concede(req.seat)
return {"conceded": True, "seat": req.seat}
@router.delete("/games/{game_id}", status_code=204)
async def delete_game(game_id: str) -> None:
"""Tear down a game session."""
if not _store.remove_session(game_id):
raise HTTPException(404, f"Game {game_id!r} not found.")
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _get_session(game_id: str) -> GameSession:
session = _store.get_session(game_id)
if session is None:
raise HTTPException(404, f"Game {game_id!r} not found.")
return session
-130
View File
@@ -1,130 +0,0 @@
"""
Commander AI Lab — Scryfall Art Proxy (Phase 5)
================================================
Endpoint: GET /api/v1/art/{card_name}
Proxy for Scryfall card art images.
Behaviour:
1. Look up card in local LRU disk cache (~/.cache/commander_ai_lab/art/).
2. If cached, stream the JPEG directly (cache-control: 7 days).
3. If not cached, fetch from Scryfall API, store to disk, then stream.
4. On Scryfall failure, return 404 with JSON detail.
Rate limiting: Scryfall asks for ≤10 req/s. The proxy uses an asyncio
semaphore (max 8 concurrent outbound fetches) and a 50 ms delay between
requests to stay comfortably under the limit.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, JSONResponse
router = APIRouter(tags=["art"])
_CACHE_DIR = Path(os.path.expanduser("~/.cache/commander_ai_lab/art"))
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
_SCRYFALL_NAMED = "https://api.scryfall.com/cards/named?fuzzy={name}"
_FETCH_SEM = asyncio.Semaphore(8) # max concurrent Scryfall fetches
_FETCH_DELAY = 0.05 # seconds between fetches
def _cache_path(card_name: str) -> Path:
key = hashlib.sha1(card_name.lower().encode()).hexdigest()
return _CACHE_DIR / f"{key}.jpg"
@router.get("/art/{card_name}", response_class=FileResponse)
async def get_card_art(card_name: str) -> FileResponse:
"""
Return card art JPEG for the given card name.
Resolves fuzzy name via Scryfall, caches locally.
"""
cached = _cache_path(card_name)
if cached.exists():
return FileResponse(
path=str(cached),
media_type="image/jpeg",
headers={"Cache-Control": "max-age=604800"},
)
# Fetch from Scryfall
img_bytes = await _fetch_scryfall_art(card_name)
if img_bytes is None:
raise HTTPException(404, f"Art not found for card: {card_name!r}")
cached.write_bytes(img_bytes)
return FileResponse(
path=str(cached),
media_type="image/jpeg",
headers={"Cache-Control": "max-age=604800"},
)
async def _fetch_scryfall_art(card_name: str) -> bytes | None:
"""Fetch card art bytes from Scryfall (rate-limited)."""
async with _FETCH_SEM:
await asyncio.sleep(_FETCH_DELAY)
try:
import httpx
except ImportError:
# Fall back to stdlib if httpx not installed
return await _fetch_stdlib(card_name)
async with httpx.AsyncClient(timeout=10.0) as client:
# Step 1: resolve name
url = _SCRYFALL_NAMED.format(name=quote(card_name))
r = await client.get(url)
if r.status_code != 200:
return None
data = r.json()
# Step 2: pick image URI
image_uris = data.get("image_uris") or (
data.get("card_faces", [{}])[0].get("image_uris", {})
)
img_url = image_uris.get("small") or image_uris.get("normal")
if not img_url:
return None
# Step 3: download image
r2 = await client.get(img_url)
if r2.status_code != 200:
return None
return r2.content
async def _fetch_stdlib(card_name: str) -> bytes | None:
"""Stdlib fallback for environments without httpx."""
import asyncio
from urllib.request import urlopen
from urllib.parse import quote
import json as _json
def _sync_fetch() -> bytes | None:
try:
url = _SCRYFALL_NAMED.format(name=quote(card_name))
with urlopen(url, timeout=8) as r:
data = _json.loads(r.read())
image_uris = data.get("image_uris") or (
data.get("card_faces", [{}])[0].get("image_uris", {})
)
img_url = image_uris.get("small") or image_uris.get("normal")
if not img_url:
return None
with urlopen(img_url, timeout=10) as r:
return r.read()
except Exception:
return None
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _sync_fetch)
-249
View File
@@ -1,249 +0,0 @@
"""
Commander AI Lab — Lobby REST + WebSocket Router (Phase 8)
==========================================================
Mounts at /api/v1/lobby and /ws/lobby
REST endpoints:
POST /api/v1/lobby/rooms — create room
GET /api/v1/lobby/rooms — list open rooms
GET /api/v1/lobby/rooms/{room_id} — room details
POST /api/v1/lobby/rooms/{room_id}/join — join room
POST /api/v1/lobby/rooms/{room_id}/ready — mark ready
POST /api/v1/lobby/rooms/{room_id}/launch— launch game
WebSocket:
ws://host/ws/lobby/{room_id}?name=PlayerName[&password=X][&spectate=1]
Receives: lobby_state updates (slots, ready status, chat)
Sends: {type: ready} | {type: chat, text: ...} | {type: ping}
"""
from __future__ import annotations
import json
import secrets
import asyncio
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException
from pydantic import BaseModel
from commander_ai_lab.multiplayer.lobby import LobbyManager, RoomState
from commander_ai_lab.multiplayer.chat import ChatChannel
from commander_ai_lab.multiplayer.spectator import SpectatorManager
router = APIRouter(prefix="/api/v1/lobby", tags=["lobby"])
ws_router = APIRouter(tags=["lobby-ws"])
# Module-level singletons (re-created on app startup)
_lobby: Optional[LobbyManager] = None
_spectators: Optional[SpectatorManager] = None
_chats: dict[str, ChatChannel] = {} # room_id -> ChatChannel
# WebSocket registry: room_id -> {connection_id: WebSocket}
_ws_rooms: dict[str, dict[str, WebSocket]] = {}
def get_lobby() -> LobbyManager:
global _lobby
if _lobby is None:
_lobby = LobbyManager(on_launch=_on_room_launch)
return _lobby
def get_spectators() -> SpectatorManager:
global _spectators
if _spectators is None:
_spectators = SpectatorManager()
return _spectators
async def _on_room_launch(room_id: str, room) -> None:
"""Broadcast 'game_launched' event to all lobby WebSockets in the room."""
await _broadcast_lobby_state(room_id)
async def _broadcast_lobby_state(room_id: str) -> None:
lobby = get_lobby()
room = lobby.get_room(room_id)
if room is None:
return
payload = json.dumps({"type": "lobby_state", "data": room.to_dict()})
for ws in list(_ws_rooms.get(room_id, {}).values()):
try:
await ws.send_text(payload)
except Exception:
pass
# ---------------------------------------------------------------------------
# REST — Pydantic models
# ---------------------------------------------------------------------------
class CreateRoomRequest(BaseModel):
room_name: str
host_name: str
max_players: int = 4
ai_slots: list[dict] = []
password: Optional[str] = None
class JoinRoomRequest(BaseModel):
player_name: str
password: Optional[str] = None
class ReadyRequest(BaseModel):
seat: int
ready: bool = True
# ---------------------------------------------------------------------------
# REST endpoints
# ---------------------------------------------------------------------------
@router.post("/rooms", status_code=201)
async def create_room(req: CreateRoomRequest):
lobby = get_lobby()
room_id = await lobby.create_room(
room_name=req.room_name,
host_name=req.host_name,
max_players=req.max_players,
ai_slots=req.ai_slots,
password=req.password,
)
_chats[room_id] = ChatChannel(room_id=room_id)
_ws_rooms[room_id] = {}
room = lobby.get_room(room_id)
return {"room_id": room_id, "room": room.to_dict()}
@router.get("/rooms")
async def list_rooms():
return {"rooms": get_lobby().list_open_rooms()}
@router.get("/rooms/{room_id}")
async def get_room(room_id: str):
room = get_lobby().get_room(room_id)
if room is None:
raise HTTPException(404, detail="Room not found")
return room.to_dict()
@router.post("/rooms/{room_id}/join")
async def join_room(room_id: str, req: JoinRoomRequest):
try:
seat = await get_lobby().join_room(
room_id, req.player_name, req.password
)
except ValueError as e:
raise HTTPException(400, detail=str(e))
await _broadcast_lobby_state(room_id)
return {"seat": seat}
@router.post("/rooms/{room_id}/ready")
async def set_ready(room_id: str, req: ReadyRequest):
try:
state = await get_lobby().set_ready(room_id, req.seat, req.ready)
except ValueError as e:
raise HTTPException(400, detail=str(e))
await _broadcast_lobby_state(room_id)
return {"state": state.name}
@router.post("/rooms/{room_id}/launch")
async def launch_room(room_id: str):
try:
room = await get_lobby().launch_game(room_id)
except ValueError as e:
raise HTTPException(400, detail=str(e))
return {"state": room.state.name, "room_id": room_id}
# ---------------------------------------------------------------------------
# WebSocket — Lobby room
# ---------------------------------------------------------------------------
@ws_router.websocket("/ws/lobby/{room_id}")
async def lobby_ws(
websocket: WebSocket,
room_id: str,
name: str = "Spectator",
password: Optional[str] = None,
spectate: int = 0,
):
lobby = get_lobby()
room = lobby.get_room(room_id)
if room is None:
await websocket.close(code=4404)
return
await websocket.accept()
conn_id = secrets.token_hex(4)
_ws_rooms.setdefault(room_id, {})[conn_id] = websocket
# Register spectator if requested or room is in-progress
is_spectator = bool(spectate) or room.state in (
RoomState.IN_PROGRESS, RoomState.FINISHED
)
if is_spectator:
get_spectators().add(room_id, conn_id, websocket.send_text)
chat = _chats.get(room_id)
# Send initial lobby state
await websocket.send_text(json.dumps({
"type": "lobby_state",
"data": room.to_dict(),
}))
# Send chat history
if chat:
await websocket.send_text(json.dumps({
"type": "chat_history",
"data": chat.history(last_n=50),
}))
try:
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
await websocket.send_text(json.dumps({"type": "error", "detail": "bad json"}))
continue
mtype = msg.get("type", "")
if mtype == "ping":
await websocket.send_text(json.dumps({"type": "pong"}))
elif mtype == "chat":
text = str(msg.get("text", ""))[:400]
if chat:
await chat.send(sender=name, text=text)
await _broadcast_lobby_state(room_id)
elif mtype == "ready":
seat = msg.get("seat")
if seat is not None:
try:
await lobby.set_ready(room_id, seat)
await _broadcast_lobby_state(room_id)
except ValueError as e:
await websocket.send_text(
json.dumps({"type": "error", "detail": str(e)})
)
else:
await websocket.send_text(json.dumps({
"type": "error",
"detail": f"unknown message type: {mtype}",
}))
except WebSocketDisconnect:
pass
finally:
_ws_rooms.get(room_id, {}).pop(conn_id, None)
get_spectators().remove(room_id, conn_id)
-115
View File
@@ -1,115 +0,0 @@
"""
Commander AI Lab — WebSocket Game Channel (Phase 5)
====================================================
Endpoint: ws://host/ws/game/{game_id}?seat={0-3}
Message protocol (JSON):
Client → Server:
{ "type": "move", "seat": 0, "move_id": 7 }
{ "type": "concede", "seat": 0 }
{ "type": "ping" }
Server → Client (broadcast to all connections on the same game):
{ "type": "state", "data": <GameStateSnapshot> }
{ "type": "event", "seat": N, "event_type": "...", "narration": "...", "turn": N }
{ "type": "phase", "phase": "main1", "active_seat": N, "turn": N }
{ "type": "thinking", "seat": N, "is_thinking": bool }
{ "type": "elimination", "seat": N, "player_name": "..." }
{ "type": "game_over", "winner": N, "reason": "..." }
{ "type": "pong" }
{ "type": "error", "detail": "..." }
"""
from __future__ import annotations
import json
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from commander_ai_lab.web.session_store import SessionStore
from commander_ai_lab.web.connection_manager import ConnectionManager
router = APIRouter(tags=["websocket"])
_store = SessionStore() # shares singleton with api.py via module-level instance
_manager = ConnectionManager()
@router.websocket("/ws/game/{game_id}")
async def game_websocket(
websocket: WebSocket,
game_id: str,
seat: int = Query(default=0, ge=0, le=3),
) -> None:
"""
WebSocket endpoint for a live game channel.
Each connecting client identifies its seat via the `seat` query param.
Multiple clients may observe the same game (spectators pass seat=-1 in practice
but the validator clamps to 0-3; full spectator support is Phase 8).
"""
session = _store.get_session(game_id)
if session is None:
await websocket.close(code=4004, reason=f"Game {game_id!r} not found")
return
await _manager.connect(websocket, game_id, seat)
# Register broadcast callback on session so game events reach all clients
session.set_broadcast_callback(
lambda msg: _manager.broadcast(game_id, msg)
)
# Send initial full state snapshot
await websocket.send_text(json.dumps({
"type": "state",
"data": session.state_snapshot(),
}))
try:
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
await websocket.send_text(json.dumps(
{"type": "error", "detail": "Invalid JSON"}
))
continue
msg_type = msg.get("type", "")
if msg_type == "ping":
await websocket.send_text(json.dumps({"type": "pong"}))
elif msg_type == "move":
move_seat = msg.get("seat", seat)
move_id = msg.get("move_id")
if move_id is None:
await websocket.send_text(json.dumps(
{"type": "error", "detail": "move_id is required"}
))
continue
ok = await session.apply_human_move(seat=move_seat, move_id=move_id)
if not ok:
await websocket.send_text(json.dumps(
{"type": "error", "detail": "Illegal move or not your turn"}
))
else:
# Broadcast updated state to all watchers
await _manager.broadcast(game_id, {
"type": "state",
"data": session.state_snapshot(),
})
elif msg_type == "concede":
await session.concede(seat)
else:
await websocket.send_text(json.dumps(
{"type": "error", "detail": f"Unknown message type: {msg_type!r}"}
))
except WebSocketDisconnect:
_manager.disconnect(websocket, game_id)
-214
View File
@@ -1,214 +0,0 @@
"""
Commander AI Lab — Session Store (Phase 5)
==========================================
In-memory registry of live GameSession objects.
Each session wraps:
- A CommanderGameState
- A CommanderTurnManager (running in a background asyncio task)
- A broadcast callback (set by the WebSocket router)
SessionStore is intentionally not thread-safe beyond asyncio coroutines —
all mutations happen in the async event loop.
"""
from __future__ import annotations
import asyncio
import uuid
from dataclasses import dataclass, field
from typing import Callable, Optional, Awaitable
# ---------------------------------------------------------------------------
# Stub game state / turn manager (replaced by real imports once available)
# ---------------------------------------------------------------------------
class _StubPlayer:
def __init__(self, name: str, seat: int) -> None:
self.name = name
self.seat = seat
self.life = 40
self.eliminated = False
self.hand = []
self.battlefield = []
self.graveyard = []
self.exile = []
self.command_zone = []
def to_dict(self, private: bool = False) -> dict:
return {
"name": self.name,
"seat": self.seat,
"life": self.life,
"eliminated": self.eliminated,
"hand_count": len(self.hand),
"hand": self.hand if private else [],
"battlefield": self.battlefield,
"graveyard": self.graveyard,
"exile": self.exile,
"command_zone": self.command_zone,
}
class _StubGameState:
def __init__(self, player_names: list[str]) -> None:
self.players = [_StubPlayer(n, i) for i, n in enumerate(player_names)]
self.turn = 1
self.current_phase = "main1"
self.active_player_seat = 0
self.stack = []
self.winner = None
self.game_over = False
def get_legal_moves(self, seat: int) -> list[dict]:
return [
{"id": 1, "category": "pass_priority", "description": "Pass priority"},
{"id": 2, "category": "play_land", "description": "Play a land"},
]
def apply_move(self, seat: int, move_id: int) -> None:
pass # real logic wired in by CommanderTurnManager
# ---------------------------------------------------------------------------
# GameSession
# ---------------------------------------------------------------------------
class GameSession:
"""
Wraps a single live Commander game.
Lifecycle:
1. Created by SessionStore.create_session()
2. Turn manager runs as an asyncio background task
3. WebSocket router attaches a broadcast callback
4. Destroyed by SessionStore.remove_session()
"""
def __init__(
self,
game_id: str,
player_names: list[str],
human_seat: int,
ai_personalities: list[str],
) -> None:
self.game_id = game_id
self.human_seat = human_seat
self._gs = _StubGameState(player_names)
self._pending_moves: dict[int, asyncio.Future] = {} # seat -> Future
self._broadcast: Optional[Callable] = None
self._task: Optional[asyncio.Task] = None
# ------------------------------------------------------------------
# Public API used by routers
# ------------------------------------------------------------------
def state_snapshot(self, private_seat: Optional[int] = None) -> dict:
"""Return serialisable game state dict."""
return {
"game_id": self.game_id,
"turn": self._gs.turn,
"current_phase": self._gs.current_phase,
"active_seat": self._gs.active_player_seat,
"game_over": self._gs.game_over,
"winner": self._gs.winner,
"stack_size": len(self._gs.stack),
"players": [
p.to_dict(private=(p.seat == private_seat))
for p in self._gs.players
],
}
def get_legal_moves(self, seat: int) -> list[dict]:
return self._gs.get_legal_moves(seat)
async def apply_human_move(
self,
seat: int,
move_id: int,
) -> bool:
"""
Inject a human move.
Resolves the pending Future if the turn manager is waiting.
"""
fut = self._pending_moves.get(seat)
if fut and not fut.done():
fut.set_result(move_id)
return True
# Fallback: apply directly (e.g. during testing)
self._gs.apply_move(seat, move_id)
return True
async def concede(self, seat: int) -> None:
"""Mark a seat as eliminated."""
if 0 <= seat < len(self._gs.players):
self._gs.players[seat].eliminated = True
self._gs.players[seat].life = 0
if self._broadcast:
await self._broadcast({
"type": "elimination",
"seat": seat,
"player_name": self._gs.players[seat].name,
})
def set_broadcast_callback(
self,
callback: Callable[[dict], Awaitable[None]],
) -> None:
self._broadcast = callback
def register_human_future(
self,
seat: int,
future: asyncio.Future,
) -> None:
"""Called by turn manager when waiting for human input."""
self._pending_moves[seat] = future
# ---------------------------------------------------------------------------
# SessionStore
# ---------------------------------------------------------------------------
class SessionStore:
"""
Singleton-ish in-memory store for GameSession objects.
One instance is shared between the API and WS routers via module globals.
"""
_instance: Optional[SessionStore] = None
def __new__(cls) -> SessionStore:
# Simple module-level singleton
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._sessions: dict[str, GameSession] = {}
return cls._instance
async def create_session(
self,
player_names: list[str],
human_seat: int = 0,
ai_personalities: list[str] | None = None,
) -> GameSession:
game_id = str(uuid.uuid4())[:8]
session = GameSession(
game_id=game_id,
player_names=player_names,
human_seat=human_seat,
ai_personalities=ai_personalities or ["aggressive", "control", "combo"],
)
self._sessions[game_id] = session
return session
def get_session(self, game_id: str) -> Optional[GameSession]:
return self._sessions.get(game_id)
def list_session_ids(self) -> list[str]:
return list(self._sessions.keys())
def remove_session(self, game_id: str) -> bool:
if game_id in self._sessions:
del self._sessions[game_id]
return True
return False
-489
View File
@@ -1,489 +0,0 @@
<!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</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #12141e;
color: #e8e8e8;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
#phase-bar {
display: flex;
align-items: center;
gap: 4px;
background: #1e2338;
padding: 4px 8px;
height: 34px;
flex-shrink: 0;
}
.phase-tab {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
background: #252a42;
color: #888;
transition: background 0.15s;
}
.phase-tab.active { background: #3c82dc; color: #fff; }
#active-player { font-size: 12px; font-weight: 600; margin-right: 8px; min-width: 100px; }
#main {
display: flex;
flex: 1;
overflow: hidden;
}
#board {
flex: 1;
display: grid;
grid-template-rows: 1fr 1fr 1fr;
grid-template-columns: 180px 1fr 180px;
gap: 4px;
padding: 4px;
}
.player-zone {
background: #1c2034;
border: 1px solid #3a4060;
border-radius: 6px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
}
.player-zone.active { border-color: #3c82dc; }
.player-zone.thinking{ border-color: #64b4ff; animation: pulse 1s infinite; }
.player-zone.eliminated { opacity: 0.4; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.7} }
.zone-name {
font-size: 10px;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
}
.player-name {
font-size: 13px;
font-weight: 600;
}
.life-total {
font-size: 28px;
font-weight: 700;
}
.life-high { color: #50c87a; }
.life-med { color: #dcb432; }
.life-low { color: #dc3c3c; }
.card-list {
display: flex;
flex-wrap: wrap;
gap: 3px;
overflow-y: auto;
flex: 1;
}
.card-chip {
background: #2a3050;
border: 1px solid #444;
border-radius: 4px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
transition: border-color 0.1s;
white-space: nowrap;
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
}
.card-chip:hover { border-color: #ffdc32; }
.card-chip.tapped { opacity: 0.55; font-style: italic; }
.card-chip.commander::after { content: " ★"; color: #ffd200; }
#center-col {
display: flex;
flex-direction: column;
gap: 4px;
}
#stack-zone {
background: #1c2034;
border: 1px solid #7060c0;
border-radius: 6px;
padding: 6px;
min-height: 60px;
font-size: 11px;
}
#hand-zone {
background: #1a2030;
border: 1px solid #3a4060;
border-radius: 6px;
padding: 6px;
display: flex;
flex-wrap: wrap;
gap: 4px;
min-height: 50px;
}
.hand-card {
background: #2a3868;
border: 1px solid #5060a0;
border-radius: 4px;
padding: 3px 8px;
font-size: 11px;
cursor: pointer;
}
.hand-card:hover { border-color: #ffdc32; }
.hand-card.selected { border-color: #ffdc32; background: #3a4878; }
#narration {
width: 260px;
background: #161820;
border-left: 1px solid #2a2e48;
display: flex;
flex-direction: column;
padding: 6px;
gap: 2px;
overflow-y: auto;
flex-shrink: 0;
}
.narr-line {
font-size: 11px;
line-height: 1.4;
word-break: break-word;
}
.narr-system { color: #556; }
.narr-thinking { color: #64b4ff; font-style: italic; }
#move-bar {
background: #1a1e30;
border-top: 1px solid #2a2e48;
padding: 6px 10px;
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
.move-btn {
background: #2a3868;
border: 1px solid #4060a0;
color: #ccc;
padding: 4px 14px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.move-btn:hover { background: #3a4878; border-color: #6080d0; }
.move-btn.primary { background: #2a5090; border-color: #4080d0; color: #fff; }
#status-bar {
font-size: 11px;
color: #778;
margin-left: auto;
}
#overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.8);
justify-content: center;
align-items: center;
z-index: 100;
}
#overlay.show { display: flex; }
#overlay-box {
background: #1c2034;
border: 2px solid #4060c0;
border-radius: 10px;
padding: 40px 60px;
text-align: center;
}
#overlay-title { font-size: 36px; font-weight: 700; margin-bottom: 12px; }
#overlay-sub { font-size: 16px; color: #aaa; margin-bottom: 24px; }
#overlay-stats { font-size: 13px; color: #888; line-height: 2; }
</style>
</head>
<body>
<!-- Phase bar -->
<div id="phase-bar">
<span id="active-player">—</span>
<span class="phase-tab" data-phase="untap">Untap</span>
<span class="phase-tab" data-phase="upkeep">Upkeep</span>
<span class="phase-tab" data-phase="draw">Draw</span>
<span class="phase-tab" data-phase="main1">Main 1</span>
<span class="phase-tab" data-phase="begin_combat">Combat</span>
<span class="phase-tab" data-phase="declare_attackers">Attackers</span>
<span class="phase-tab" data-phase="declare_blockers">Blockers</span>
<span class="phase-tab" data-phase="combat_damage">Damage</span>
<span class="phase-tab" data-phase="main2">Main 2</span>
<span class="phase-tab" data-phase="end_step">End</span>
</div>
<!-- Main area -->
<div id="main">
<div id="board">
<!-- Populated by JS -->
</div>
<div id="narration"></div>
</div>
<!-- Move bar -->
<div id="move-bar">
<button class="move-btn primary" id="btn-pass">Pass Priority</button>
<button class="move-btn" id="btn-concede">Concede</button>
<span id="status-bar">Connecting…</span>
</div>
<!-- Game over overlay -->
<div id="overlay">
<div id="overlay-box">
<div id="overlay-title"></div>
<div id="overlay-sub"></div>
<div id="overlay-stats"></div>
</div>
</div>
<script>
// ---------------------------------------------------------------------------
// Commander AI Lab — Web Client (Phase 5)
// ---------------------------------------------------------------------------
const SEAT_COLOURS = ['#4696ff', '#ff6450', '#50d278', '#dcaa32'];
const PHASES = [
'untap','upkeep','draw','main1','begin_combat',
'declare_attackers','declare_blockers','combat_damage','main2','end_step','cleanup'
];
let ws = null;
let gameId = null;
let humanSeat = 0;
let gameState = null;
let selected = null; // selected hand card index
// ---------------------------------------------------------------------------
// Boot: create or join a game via REST, then open WebSocket
// ---------------------------------------------------------------------------
async function boot() {
setStatus('Creating game…');
try {
const res = await fetch('/api/v1/games', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_names: ['You', 'AI-Timmy', 'AI-Spike', 'AI-Johnny'],
human_seat: 0,
ai_personality: ['aggressive', 'control', 'combo'],
}),
});
const data = await res.json();
gameId = data.game_id;
humanSeat = 0;
setStatus(`Game ${gameId} — connecting WebSocket…`);
openSocket();
} catch (e) {
setStatus('Failed to create game: ' + e.message);
}
}
function openSocket() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/game/${gameId}?seat=${humanSeat}`);
ws.onopen = () => setStatus(`Connected — Game ${gameId}`);
ws.onmessage = (e) => handleMessage(JSON.parse(e.data));
ws.onclose = () => setStatus('Disconnected');
ws.onerror = () => setStatus('WebSocket error');
// Heartbeat
setInterval(() => { if (ws.readyState === 1) ws.send(JSON.stringify({type:'ping'})); }, 15000);
}
// ---------------------------------------------------------------------------
// Message handler
// ---------------------------------------------------------------------------
function handleMessage(msg) {
switch (msg.type) {
case 'state': gameState = msg.data; render(); break;
case 'event': addNarration(msg.seat, msg.narration || msg.event_type); break;
case 'phase': addNarration(-1, `T${msg.turn} — ${msg.phase}`, true); render(); break;
case 'thinking': handleThinking(msg.seat, msg.is_thinking); break;
case 'elimination': addNarration(msg.seat, `${msg.player_name} eliminated!`); break;
case 'game_over': showGameOver(msg); break;
case 'pong': break;
case 'error': addNarration(-1, '⚠ ' + msg.detail, true); break;
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function render() {
if (!gameState) return;
updatePhaseBar(gameState.current_phase, gameState.active_seat);
renderBoard(gameState);
}
function updatePhaseBar(phase, activeSeat) {
document.querySelectorAll('.phase-tab').forEach(el => {
el.classList.toggle('active', el.dataset.phase === phase);
});
const ap = document.getElementById('active-player');
ap.textContent = gameState?.players?.[activeSeat]?.name || '—';
ap.style.color = SEAT_COLOURS[activeSeat % SEAT_COLOURS.length];
}
function renderBoard(gs) {
const board = document.getElementById('board');
board.innerHTML = '';
gs.players.forEach((p, seat) => {
const zone = document.createElement('div');
zone.className = 'player-zone'
+ (seat === gs.active_seat ? ' active' : '')
+ (p.eliminated ? ' eliminated': '');
zone.style.borderColor = SEAT_COLOURS[seat % SEAT_COLOURS.length];
// Header
const lifeClass = p.life >= 30 ? 'life-high' : p.life >= 15 ? 'life-med' : 'life-low';
zone.innerHTML = `
<div class="player-name" style="color:${SEAT_COLOURS[seat]}">${p.name}</div>
<div class="life-total ${lifeClass}">${p.life}</div>
<div class="zone-name">Battlefield (${p.battlefield.length})</div>
<div class="card-list" id="bf-${seat}"></div>
`;
// Battlefield cards
const bf = zone.querySelector(`#bf-${seat}`);
(p.battlefield || []).forEach((card, idx) => {
const chip = document.createElement('div');
chip.className = 'card-chip'
+ (card.tapped ? ' tapped' : '')
+ (card.is_commander ? ' commander' : '');
chip.textContent = card.name || '?';
chip.title = card.oracle || '';
bf.appendChild(chip);
});
board.appendChild(zone);
});
// Human hand (seat 0)
renderHand(gs);
}
function renderHand(gs) {
let handZone = document.getElementById('hand-zone');
if (!handZone) {
handZone = document.createElement('div');
handZone.id = 'hand-zone';
document.getElementById('move-bar').prepend(handZone);
}
handZone.innerHTML = '';
const hand = gs.players[humanSeat]?.hand || [];
hand.forEach((card, idx) => {
const el = document.createElement('div');
el.className = 'hand-card' + (selected === idx ? ' selected' : '');
el.textContent = card.name || `Card ${idx}`;
el.onclick = () => selectCard(idx);
handZone.appendChild(el);
});
}
function selectCard(idx) {
if (selected === idx) {
// Second click = play
playCard(idx);
selected = null;
} else {
selected = idx;
}
renderHand(gameState);
}
async function playCard(idx) {
if (!gameId) return;
const moves = await (await fetch(`/api/v1/games/${gameId}/moves?seat=${humanSeat}`)).json();
const move = moves.moves?.[0];
if (move && ws?.readyState === 1) {
ws.send(JSON.stringify({type: 'move', seat: humanSeat, move_id: move.id}));
}
}
// ---------------------------------------------------------------------------
// Narration panel
// ---------------------------------------------------------------------------
const _thinkingIntervals = {};
function addNarration(seat, text, isSystem = false) {
const panel = document.getElementById('narration');
const line = document.createElement('div');
line.className = 'narr-line ' + (isSystem ? 'narr-system' : '');
line.style.color = seat >= 0 ? SEAT_COLOURS[seat % SEAT_COLOURS.length] : '#556';
line.textContent = text;
panel.appendChild(line);
// Keep last 120 lines
while (panel.children.length > 120) panel.removeChild(panel.firstChild);
panel.scrollTop = panel.scrollHeight;
}
function handleThinking(seat, isThinking) {
const id = `thinking-${seat}`;
if (isThinking) {
if (!document.getElementById(id)) {
const el = document.createElement('div');
el.id = id;
el.className = 'narr-line narr-thinking';
document.getElementById('narration').appendChild(el);
let dots = 0;
_thinkingIntervals[seat] = setInterval(() => {
el.textContent = `P${seat} thinking${'…'.repeat((++dots % 3) + 1)}`;
}, 400);
}
} else {
clearInterval(_thinkingIntervals[seat]);
delete _thinkingIntervals[seat];
document.getElementById(id)?.remove();
}
}
// ---------------------------------------------------------------------------
// Game over overlay
// ---------------------------------------------------------------------------
function showGameOver(msg) {
const overlay = document.getElementById('overlay');
const winner = gameState?.players?.[msg.winner];
document.getElementById('overlay-title').textContent =
winner ? `${winner.name} wins!` : 'Game Over';
document.getElementById('overlay-title').style.color =
winner ? SEAT_COLOURS[msg.winner % SEAT_COLOURS.length] : '#fff';
document.getElementById('overlay-sub').textContent = `by ${msg.reason || 'unknown'}`;
const stats = (gameState?.players || []).map((p, i) =>
`${p.eliminated ? '[ELIM] ' : ' '}P${i} ${p.name.padEnd(16)} Life: ${p.life}`
).join('\n');
document.getElementById('overlay-stats').textContent = stats;
overlay.classList.add('show');
}
// ---------------------------------------------------------------------------
// Move bar buttons
// ---------------------------------------------------------------------------
document.getElementById('btn-pass').onclick = () => {
if (!gameId || !ws || ws.readyState !== 1) return;
ws.send(JSON.stringify({type: 'move', seat: humanSeat, move_id: 1}));
};
document.getElementById('btn-concede').onclick = async () => {
if (!gameId) return;
if (!confirm('Concede?')) return;
await fetch(`/api/v1/games/${gameId}/concede`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({seat: humanSeat}),
});
};
function setStatus(text) {
document.getElementById('status-bar').textContent = text;
}
// Boot on load
window.addEventListener('load', boot);
</script>
</body>
</html>
@@ -44,7 +44,7 @@ public class BatchRunner {
private final String forgeJarPath; // Path to forge-gui-desktop JAR (jar-with-dependencies)
private final String forgeWorkDir; // Working directory for Forge (where res/ folder is)
private final List<DeckInfo> decks; // 3 or 4 decks
private final List<DeckInfo> decks; // Exactly 3 decks
private final AiPolicy policy;
private final boolean quiet;
private final int clockSeconds; // Max seconds per game before draw (default 120)
@@ -94,12 +94,7 @@ public class BatchRunner {
Pattern.compile("Match Result:");
// ── Life total tracking patterns (Issue #1) ─────────────────────────
// Forge verbose log format: "Life: Life: Ai(1)-Name 40 > 42" (old life > new life)
// Also support older format: "Ai(1)-Name's life is now 27."
private static final Pattern LIFE_CHANGE_PATTERN =
Pattern.compile("Life:\\s*Life:\\s*Ai\\((\\d+)\\)-.+?\\s+(\\d+)\\s*>\\s*(-?\\d+)");
// Fallback: "Ai(1)-Name's life is now 27." or "Ai(1)-Name's life total is now 27"
// Verbose Forge log: "Ai(1)-Name's life is now 27." or "Ai(1)-Name's life total is now 27"
private static final Pattern LIFE_TOTAL_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-.+?'s\\s+life(?:\\s+total)?\\s+is\\s+now\\s+(-?\\d+)", Pattern.CASE_INSENSITIVE);
@@ -113,21 +108,15 @@ public class BatchRunner {
// ── Verbose game-log patterns for extracting combat stats ────────────
// "Add To Stack: Ai(1)-Name cast CardName" — Forge verbose log spell cast
// Format: "Add To Stack: Ai(N)-DeckName cast CardName"
// Note: Forge uses past tense "cast" not "casts"
// "Ai(1)-Name casts CardName" — any spell cast
private static final Pattern CAST_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+cast\\s+(.+?)(?:\\s+targeting.*)?$", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+casts\\s+(.+?)\\.", Pattern.CASE_INSENSITIVE);
// "Land: Ai(1)-Name played LandName (SET)" or "Ai(1)-Name plays LandName."
// Forge verbose log uses "played" (past tense) with set info in parens, no trailing period
// "Ai(1)-Name plays Land" — land drop
private static final Pattern LAND_PLAY_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+play(?:s|ed)\\s+(.+?)(?:\\s+\\(\\d+\\))?(?:\\.|$)", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+plays\\s+(.+?)\\.", Pattern.CASE_INSENSITIVE);
// "Zone Change: CardName (N) was put into Graveyard from Battlefield." — creature/permanent death
// Also: "is destroyed" / "dies" from older format
private static final Pattern ZONE_CHANGE_PATTERN =
Pattern.compile("Zone Change:\\s*(.+?)\\s+(?:\\(\\d+\\)\\s+)?was put into .* from Battlefield", Pattern.CASE_INSENSITIVE);
// "...is destroyed" or "...is put into graveyard from the battlefield"
private static final Pattern CREATURE_DESTROYED_PATTERN =
Pattern.compile("(is destroyed|dies|is put into .* graveyard from the battlefield)", Pattern.CASE_INSENSITIVE);
@@ -141,11 +130,9 @@ public class BatchRunner {
// ── Per-card tracking patterns (verbose log) ─────────────────────────
// Card draw tracking: Forge verbose log does NOT log individual card draws.
// This pattern is kept as a fallback for other Forge output modes.
// In verbose mode, draws can only be inferred from "Draw step" phase entries.
// "Ai(1)-Name draws CardName." — card drawn
private static final Pattern DRAW_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+draws\\s+(.+?)(?:\\.|$)", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+draws\\s+(.+?)\\.", Pattern.CASE_INSENSITIVE);
// Turn boundary: "Turn 5 (Ai(1)-Name)" or "Turn 5 (Ai(2)-Name)"
// Some Forge versions use: "== Turn X (Ai(N)-Name) =="
@@ -157,9 +144,7 @@ public class BatchRunner {
Pattern.compile("(.+?)\\s+deals\\s+(\\d+)\\s+(?:combat\\s+)?damage", Pattern.CASE_INSENSITIVE);
public BatchRunner(String forgeJarPath, String forgeWorkDir, List<DeckInfo> decks, AiPolicy policy) {
// 4-player Commander games need more time — 300s (5min) default instead of 120s
this(forgeJarPath, forgeWorkDir, decks, policy, false,
decks.size() >= 4 ? 300 : 180); // verbose by default for combat stats
this(forgeJarPath, forgeWorkDir, decks, policy, false, 120); // verbose by default for combat stats
}
public BatchRunner(String forgeJarPath, String forgeWorkDir, List<DeckInfo> decks,
@@ -169,8 +154,8 @@ public class BatchRunner {
public BatchRunner(String forgeJarPath, String forgeWorkDir, List<DeckInfo> decks,
AiPolicy policy, boolean quiet, int clockSeconds, String javaPath) {
if (decks.size() < 3 || decks.size() > 4) {
throw new IllegalArgumentException("Requires 3 or 4 decks, got " + decks.size());
if (decks.size() != 3) {
throw new IllegalArgumentException("v1 requires exactly 3 decks, got " + decks.size());
}
this.forgeJarPath = forgeJarPath;
this.forgeWorkDir = forgeWorkDir;
@@ -549,7 +534,7 @@ public class BatchRunner {
// Initialize player results for all 3 seats
result.playerResults = new ArrayList<>();
for (int i = 0; i < decks.size(); i++) {
for (int i = 0; i < 3; i++) {
PlayerResult pr = new PlayerResult();
pr.seatIndex = i;
pr.finalLife = 40; // Commander starting life
@@ -572,16 +557,9 @@ public class BatchRunner {
Map<String, String> lossReasons = new HashMap<>();
// ── Issue #1: Track last-known life totals from verbose log ──────
int numPlayers = decks.size();
int[] lastKnownLife = new int[numPlayers];
boolean[] lifeTracked = new boolean[numPlayers];
for (int i = 0; i < numPlayers; i++) {
lastKnownLife[i] = 40; // Commander starting life
lifeTracked[i] = false;
}
int[] lastKnownLife = {40, 40, 40}; // Start at Commander default
boolean[] lifeTracked = {false, false, false};
Set<Integer> loserSeats = new HashSet<>();
Set<Integer> winnerCandidates = new HashSet<>(); // Track all "has won" seats
int maxVerboseTurn = 0; // Fallback turn count from verbose log
for (String line : lines) {
line = line.trim();
@@ -593,24 +571,16 @@ public class BatchRunner {
continue;
}
// Also track turns from verbose output: "Turn: Turn 5 (Ai(1)-Name)"
Matcher verboseTurn = VERBOSE_TURN_PATTERN.matcher(line);
if (verboseTurn.find()) {
int t = Integer.parseInt(verboseTurn.group(1));
if (t > maxVerboseTurn) maxVerboseTurn = t;
// Don't continue — let other patterns also check this line
}
// Parse winner: "Game Outcome: Ai(1)-Edgar Markov has won because ..."
// In 4-player Commander, Forge may mark multiple survivors as "won" on timeout.
// Collect all candidates; resolve to single winner after loop.
Matcher winMatcher = WINNER_PATTERN.matcher(line);
if (winMatcher.find()) {
int aiNumber = Integer.parseInt(winMatcher.group(1));
int seatIndex = aiNumber - 1; // Forge uses 1-based, we use 0-based
String winReason = winMatcher.group(3).trim();
if (seatIndex >= 0 && seatIndex < decks.size()) {
winnerCandidates.add(seatIndex);
if (seatIndex >= 0 && seatIndex < 3) {
result.winningSeat = seatIndex;
result.playerResults.get(seatIndex).isWinner = true;
}
continue;
}
@@ -622,7 +592,7 @@ public class BatchRunner {
int seatIndex = aiNumber - 1;
String lossReason = loseMatcher.group(3).trim();
if (seatIndex >= 0 && seatIndex < decks.size()) {
if (seatIndex >= 0 && seatIndex < 3) {
lossReasons.put(String.valueOf(seatIndex), lossReason);
loserSeats.add(seatIndex);
@@ -662,29 +632,17 @@ public class BatchRunner {
}
// ── Issue #1: Track life totals from verbose log ────────────
// Primary format: "Life: Life: Ai(1)-Name 40 > 42" (old > new)
Matcher lifeChangeMatch = LIFE_CHANGE_PATTERN.matcher(line);
if (lifeChangeMatch.find()) {
int aiNum = Integer.parseInt(lifeChangeMatch.group(1));
int seat = aiNum - 1;
int newLife = Integer.parseInt(lifeChangeMatch.group(3));
if (seat >= 0 && seat < decks.size()) {
lastKnownLife[seat] = newLife;
lifeTracked[seat] = true;
}
// Don't continue — other patterns may also match this line
}
// Fallback: "Ai(1)-Name's life is now 27"
// "Ai(1)-Name's life is now 27"
Matcher lifeMatch = LIFE_TOTAL_PATTERN.matcher(line);
if (lifeMatch.find()) {
int aiNum = Integer.parseInt(lifeMatch.group(1));
int seat = aiNum - 1;
int life = Integer.parseInt(lifeMatch.group(2));
if (seat >= 0 && seat < decks.size()) {
if (seat >= 0 && seat < 3) {
lastKnownLife[seat] = life;
lifeTracked[seat] = true;
}
// Don't continue — other patterns may also match this line
}
// "Ai(1)-Name loses N life"
@@ -693,7 +651,7 @@ public class BatchRunner {
int aiNum = Integer.parseInt(lossMatch.group(1));
int seat = aiNum - 1;
int amount = Integer.parseInt(lossMatch.group(2));
if (seat >= 0 && seat < decks.size()) {
if (seat >= 0 && seat < 3) {
lastKnownLife[seat] -= amount;
lifeTracked[seat] = true;
}
@@ -705,7 +663,7 @@ public class BatchRunner {
int aiNum = Integer.parseInt(gainMatch.group(1));
int seat = aiNum - 1;
int amount = Integer.parseInt(gainMatch.group(2));
if (seat >= 0 && seat < decks.size()) {
if (seat >= 0 && seat < 3) {
lastKnownLife[seat] += amount;
lifeTracked[seat] = true;
}
@@ -717,78 +675,52 @@ public class BatchRunner {
if (mullMatcher.find()) {
int aiNum = Integer.parseInt(mullMatcher.group(1));
int seat = aiNum - 1;
if (seat >= 0 && seat < decks.size()) {
if (seat >= 0 && seat < 3) {
result.playerResults.get(seat).mulligans++;
}
}
}
}
// ── Resolve single winner from candidates ────────────────────
// In 4-player Commander, Forge may mark multiple survivors as "won" on clock timeout.
// Resolve to a single winner: if exactly 1 candidate, they win.
// If multiple candidates (timeout scenario), pick the one with highest life.
if (winnerCandidates.size() == 1) {
int winSeat = winnerCandidates.iterator().next();
result.winningSeat = winSeat;
result.playerResults.get(winSeat).isWinner = true;
System.out.printf("[WINNER] Single winner: seat %d%n", winSeat);
} else if (winnerCandidates.size() > 1) {
// Multiple "winners" — Forge timeout scenario.
// Pick the player with the highest tracked life total.
int bestSeat = -1;
int bestLife = Integer.MIN_VALUE;
for (int seat : winnerCandidates) {
int life = lifeTracked[seat] ? lastKnownLife[seat] : 40;
if (life > bestLife) {
bestLife = life;
bestSeat = seat;
}
}
if (bestSeat >= 0) {
result.winningSeat = bestSeat;
result.playerResults.get(bestSeat).isWinner = true;
System.out.printf("[WINNER] Resolved from %d candidates: seat %d (life=%d). Others were survivors on timeout.%n",
winnerCandidates.size(), bestSeat, bestLife);
}
}
// If no winner candidates and no losers, winningSeat stays null (draw/crash)
// ── Issue #2: Classify win condition using formal enum ───────────
WinCondition condition = WinCondition.classify(lossReasons, output);
result.winCondition = condition.getLabel();
// ── Issue #1: Set final life totals from verbose log tracking ────
// Update ALL players' final life from verbose tracking data
for (int seat = 0; seat < decks.size(); seat++) {
if (lifeTracked[seat]) {
result.playerResults.get(seat).finalLife = lastKnownLife[seat];
}
}
// ── Issue #1: Set winner's final life total ─────────────────────
// Strategy: Use last-known life from verbose log if available,
// otherwise winner keeps default 40 (which is wrong but was the old behavior).
// If we tracked life for the winner, use that.
if (result.winningSeat != null) {
int winnerSeat = result.winningSeat;
if (lifeTracked[winnerSeat]) {
result.playerResults.get(winnerSeat).finalLife = lastKnownLife[winnerSeat];
System.out.printf("[LIFE] Winner seat %d: final life = %d (tracked from log)%n",
winnerSeat, lastKnownLife[winnerSeat]);
} else {
// Verbose tracking didn't capture — leave at 40 but log it
System.out.printf("[LIFE] Winner seat %d: life not tracked in log (quiet mode?), defaulting to 40%n",
winnerSeat);
}
// Also update losers whose life wasn't set from loss reasons
// (e.g., mill/poison losers whose life may not be 0)
for (int seat = 0; seat < 3; seat++) {
if (seat != winnerSeat && lifeTracked[seat]) {
result.playerResults.get(seat).finalLife = lastKnownLife[seat];
}
}
}
// Default turn count: use verbose turn tracking as fallback
if (result.totalTurns == 0 && maxVerboseTurn > 0) {
result.totalTurns = maxVerboseTurn;
System.out.printf("[TURNS] Using verbose log turn count: %d%n", maxVerboseTurn);
} else if (result.totalTurns == 0) {
// Default turn count if not parsed
if (result.totalTurns == 0) {
result.totalTurns = 1;
}
// ── Parse verbose game log for combat stats ─────────────────────
// These patterns match Forge's full game log (non-quiet mode)
// Track commander names for each seat to detect commander casts
String[] commanderNames = new String[decks.size()];
for (int ci = 0; ci < decks.size(); ci++) {
String[] commanderNames = new String[3];
for (int ci = 0; ci < 3 && ci < decks.size(); ci++) {
commanderNames[ci] = decks.get(ci).commanderName;
}
@@ -802,7 +734,7 @@ public class BatchRunner {
int aiNum = Integer.parseInt(castMatcher.group(1));
int seat = aiNum - 1;
String cardName = castMatcher.group(2).trim();
if (seat >= 0 && seat < decks.size()) {
if (seat >= 0 && seat < 3) {
result.playerResults.get(seat).spellsCast++;
// Check if this is the commander being cast
if (commanderNames[seat] != null &&
@@ -818,7 +750,7 @@ public class BatchRunner {
if (landMatcher.find()) {
int aiNum = Integer.parseInt(landMatcher.group(1));
int seat = aiNum - 1;
if (seat >= 0 && seat < decks.size()) {
if (seat >= 0 && seat < 3) {
result.playerResults.get(seat).landsPlayed++;
}
continue;
@@ -844,7 +776,7 @@ public class BatchRunner {
int aiNum = Integer.parseInt(aiRef.group(1));
int victimSeat = aiNum - 1;
// Credit all non-victim seats with 1 creature destroyed
for (int s = 0; s < decks.size(); s++) {
for (int s = 0; s < 3; s++) {
if (s != victimSeat && s < result.playerResults.size()) {
// We can't know who did it, so we don't increment here
// Instead, use a simpler approach below
@@ -863,7 +795,7 @@ public class BatchRunner {
Matcher srcPlayer = Pattern.compile("Ai\\((\\d+)\\)").matcher(line);
if (srcPlayer.find()) {
int seat = Integer.parseInt(srcPlayer.group(1)) - 1;
if (seat >= 0 && seat < decks.size()) {
if (seat >= 0 && seat < 3) {
result.playerResults.get(seat).commanderDamageDealt += amount;
}
}
@@ -873,7 +805,7 @@ public class BatchRunner {
// Estimate creatures destroyed from total turns if verbose parsing didn't catch specifics
// (Forge log format varies; this provides a reasonable fallback)
for (int seat = 0; seat < result.playerResults.size(); seat++) {
for (int seat = 0; seat < 3 && seat < result.playerResults.size(); seat++) {
PlayerResult pr = result.playerResults.get(seat);
// If we got no spell data from parsing, it means either quiet mode or no matches
// Leave at 0 rather than fabricating data
@@ -892,6 +824,7 @@ public class BatchRunner {
// ── ML Decision extraction (for RL training data) ─────────────────
if (mlLoggingEnabled && !quiet && mlDecisionLogger != null) {
try {
int numPlayers = decks.size();
String gid = "game-" + gameIndex + "-" + gameSeed;
String[] cmdNames = new String[numPlayers];
List<List<String>> deckCardLists = new ArrayList<>();
@@ -1114,8 +1047,8 @@ public class BatchRunner {
private void buildPerCardStats(GameResult result, String[] lines) {
// Initialize per-card maps for each seat from deck files
@SuppressWarnings("unchecked")
Map<String, PerCardGameStats>[] seatCards = new Map[decks.size()];
for (int s = 0; s < decks.size(); s++) {
Map<String, PerCardGameStats>[] seatCards = new Map[3];
for (int s = 0; s < 3 && s < decks.size(); s++) {
seatCards[s] = new LinkedHashMap<>();
List<String> cardNames = readDeckCardNames(decks.get(s).deckFile);
for (String name : cardNames) {
@@ -1127,8 +1060,7 @@ public class BatchRunner {
// Track current turn per seat
int globalTurn = 0;
boolean[] openingHandPhase = new boolean[decks.size()];
java.util.Arrays.fill(openingHandPhase, true); // before first turn
boolean[] openingHandPhase = {true, true, true}; // before first turn
for (String rawLine : lines) {
String line = rawLine.trim();
@@ -1141,7 +1073,7 @@ public class BatchRunner {
int aiNum = Integer.parseInt(turnMatch.group(2));
globalTurn = turnNum;
int seat = aiNum - 1;
if (seat >= 0 && seat < decks.size()) {
if (seat >= 0 && seat < 3) {
openingHandPhase[seat] = false;
}
continue;
@@ -1152,7 +1084,7 @@ public class BatchRunner {
if (drawMatch.find()) {
int seat = Integer.parseInt(drawMatch.group(1)) - 1;
String cardName = drawMatch.group(2).trim();
if (seat >= 0 && seat < decks.size() && seatCards[seat] != null) {
if (seat >= 0 && seat < 3 && seatCards[seat] != null) {
PerCardGameStats stats = seatCards[seat].get(cardName);
if (stats == null) {
// Card not in deck list (token draw, etc.) — create entry
@@ -1176,7 +1108,7 @@ public class BatchRunner {
if (castMatch.find()) {
int seat = Integer.parseInt(castMatch.group(1)) - 1;
String cardName = castMatch.group(2).trim();
if (seat >= 0 && seat < decks.size() && seatCards[seat] != null) {
if (seat >= 0 && seat < 3 && seatCards[seat] != null) {
PerCardGameStats stats = seatCards[seat].get(cardName);
if (stats == null) {
stats = new PerCardGameStats(cardName);
@@ -1200,7 +1132,7 @@ public class BatchRunner {
if (landMatch.find()) {
int seat = Integer.parseInt(landMatch.group(1)) - 1;
String cardName = landMatch.group(2).trim();
if (seat >= 0 && seat < decks.size() && seatCards[seat] != null) {
if (seat >= 0 && seat < 3 && seatCards[seat] != null) {
PerCardGameStats stats = seatCards[seat].get(cardName);
if (stats == null) {
stats = new PerCardGameStats(cardName);
@@ -1224,7 +1156,7 @@ public class BatchRunner {
String cardName = cardDmgMatch.group(1).trim();
int amount = Integer.parseInt(cardDmgMatch.group(2));
// Try to find which seat owns this card
for (int s = 0; s < decks.size(); s++) {
for (int s = 0; s < 3; s++) {
if (seatCards[s] != null && seatCards[s].containsKey(cardName)) {
seatCards[s].get(cardName).damageDealt += amount;
break; // attribute to first matching seat
@@ -1234,7 +1166,7 @@ public class BatchRunner {
}
// Finalize: compute stuckInHand and attach to PlayerResults
for (int s = 0; s < result.playerResults.size(); s++) {
for (int s = 0; s < 3 && s < result.playerResults.size(); s++) {
if (seatCards[s] == null) continue;
List<PerCardGameStats> cardStatsList = new ArrayList<>();
for (PerCardGameStats stats : seatCards[s].values()) {
@@ -1257,7 +1189,7 @@ public class BatchRunner {
result.winCondition = WinCondition.TIMEOUT.getLabel();
result.elapsedMs = elapsedMs;
result.playerResults = new ArrayList<>();
for (int i = 0; i < decks.size(); i++) {
for (int i = 0; i < 3; i++) {
PlayerResult pr = new PlayerResult();
pr.seatIndex = i;
pr.finalLife = 40;
+12 -32
View File
@@ -58,10 +58,6 @@ public class LabCli implements Callable<Integer> {
description = "Commander deck name for seat 2")
private String deck3;
@Option(names = {"--deck4", "-d4"}, defaultValue = "",
description = "Commander deck name for seat 3")
private String deck4;
@Option(names = {"--commander1", "-c1"}, defaultValue = "",
description = "Commander card name for seat 0 (for display; uses deck name if blank)")
private String commander1;
@@ -74,10 +70,6 @@ public class LabCli implements Callable<Integer> {
description = "Commander card name for seat 2")
private String commander3;
@Option(names = {"--commander4", "-c4"}, defaultValue = "",
description = "Commander card name for seat 3")
private String commander4;
@Option(names = {"--games", "-n"}, defaultValue = "10",
description = "Number of games to simulate (default: 10)")
private int numGames;
@@ -94,8 +86,8 @@ public class LabCli implements Callable<Integer> {
description = "Output JSON file path")
private String outputPath;
@Option(names = {"--clock", "-c"}, defaultValue = "300",
description = "Max seconds per game before draw (default: 300 for 4-player Commander)")
@Option(names = {"--clock", "-c"}, defaultValue = "120",
description = "Max seconds per game before draw (default: 120)")
private int clockSeconds;
@Option(names = {"--verbose", "-v"}, defaultValue = "false",
@@ -383,7 +375,6 @@ public class LabCli implements Callable<Integer> {
result.metadata.masterSeed = masterSeed;
result.metadata.threads = threads;
result.metadata.elapsedMs = elapsedMs;
result.metadata.podSize = decks.size();
result.decks = decks;
result.games = games;
result.summary = summary;
@@ -440,15 +431,15 @@ public class LabCli implements Callable<Integer> {
// ══════════════════════════════════════════════════════════
private List<DeckInfo> buildDeckInfoList(String importDir) throws Exception {
String[] deckNames = {deck1, deck2, deck3, deck4};
String[] commanders = {commander1, commander2, commander3, commander4};
String[] deckNames = {deck1, deck2, deck3};
String[] commanders = {commander1, commander2, commander3};
// Build import lists for each seat
DeckProfile[] imports = new DeckProfile[4];
DeckProfile[] imports = new DeckProfile[3];
// Process --commander-meta flags
if (commanderMeta != null) {
for (int i = 0; i < Math.min(commanderMeta.size(), 4); i++) {
for (int i = 0; i < Math.min(commanderMeta.size(), 3); i++) {
String cmdrName = commanderMeta.get(i);
System.out.printf("═══ IMPORT SEAT %d: Commander Meta — %s ═══%n", i, cmdrName);
imports[i] = DeckImporter.importFromEdhrec(cmdrName);
@@ -457,7 +448,7 @@ public class LabCli implements Callable<Integer> {
// Process --import-url flags (overrides commander-meta for same seat)
if (importUrls != null) {
for (int i = 0; i < Math.min(importUrls.size(), 4); i++) {
for (int i = 0; i < Math.min(importUrls.size(), 3); i++) {
if (imports[i] != null) continue; // Don't override if already set
String url = importUrls.get(i);
int seat = findNextEmptySeat(imports);
@@ -469,7 +460,7 @@ public class LabCli implements Callable<Integer> {
// Process --import-text flags
if (importTextFiles != null) {
for (int i = 0; i < Math.min(importTextFiles.size(), 4); i++) {
for (int i = 0; i < Math.min(importTextFiles.size(), 3); i++) {
String filePath = importTextFiles.get(i);
int seat = findNextEmptySeat(imports);
if (seat < 0) break;
@@ -480,7 +471,7 @@ public class LabCli implements Callable<Integer> {
}
// Save imported decks to .dck files and set deck names
for (int i = 0; i < 4; i++) {
for (int i = 0; i < 3; i++) {
if (imports[i] != null) {
DeckProfile profile = imports[i];
Path dckPath = DeckImporter.saveToDckFile(profile, importDir);
@@ -494,19 +485,8 @@ public class LabCli implements Callable<Integer> {
}
}
// Count how many seats have decks (support 3 or 4 player games)
int numSeats = 0;
for (int i = 0; i < 4; i++) {
if (deckNames[i] != null && !deckNames[i].isEmpty()) {
numSeats = i + 1;
}
}
if (numSeats < 3) {
System.err.println("ERROR: At least 3 decks are required. Use --deck1, --deck2, --deck3 (and optionally --deck4).");
return null;
}
// Validate all seats up to numSeats have decks
for (int i = 0; i < numSeats; i++) {
// Validate all 3 seats have decks
for (int i = 0; i < 3; i++) {
if (deckNames[i] == null || deckNames[i].isEmpty()) {
System.err.printf("ERROR: Seat %d has no deck. Use --deck%d, --import-url, --import-text, or --commander-meta.%n", i, i + 1);
return null;
@@ -515,7 +495,7 @@ public class LabCli implements Callable<Integer> {
// Build DeckInfo list
List<DeckInfo> list = new ArrayList<>();
for (int i = 0; i < numSeats; i++) {
for (int i = 0; i < 3; i++) {
DeckInfo d = new DeckInfo();
d.seatIndex = i;
d.deckFile = deckNames[i];
@@ -62,11 +62,11 @@ public class JsonExporter {
try {
BatchResult result = GSON.fromJson(json, BatchResult.class);
return result.metadata != null
&& result.decks != null && result.decks.size() >= 3 && result.decks.size() <= 4
&& result.decks != null && result.decks.size() == 3
&& result.games != null
&& result.summary != null
&& result.metadata.schemaVersion.equals("1.0.0")
&& result.metadata.podSize >= 3 && result.metadata.podSize <= 4;
&& result.metadata.podSize == 3;
} catch (Exception e) {
return false;
}
+5 -15
View File
@@ -20,21 +20,11 @@ set FORGE_DECKS_DIR=%APPDATA%\Forge\decks\commander
REM ── Lab JAR (auto-detected from target/) ───────────────────
set LAB_PORT=8080
REM ── API Keys (set via environment variables or .env file) ──
REM If not already set in your environment, create a .env file
REM in this directory (see .env.example) or set them manually:
REM set XIMILAR_API_KEY=your-ximilar-key-here
REM set PPLX_API_KEY=your-perplexity-key-here
REM
REM Load from .env file if it exists and vars aren't already set
if not defined XIMILAR_API_KEY (
if exist "%~dp0.env" (
for /f "usebackq tokens=1,* delims==" %%A in ("%~dp0.env") do (
if "%%A"=="XIMILAR_API_KEY" set XIMILAR_API_KEY=%%B
if "%%A"=="PPLX_API_KEY" set PPLX_API_KEY=%%B
)
)
)
REM ── Ximilar API Key for Card Scanner ────────────────────────
set XIMILAR_API_KEY=96c7dab35ddbd8829b04c0f5bcea57f5ede20496
REM ── Perplexity API Key for AI Deck Research/Generation ─────
set PPLX_API_KEY=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
echo.
echo ╔══════════════════════════════════════════════════╗
View File
Whitespace-only changes.
-175
View File
@@ -1,175 +0,0 @@
"""
Commander AI Lab — Shared Test Fixtures (Phase 7)
==================================================
Provides pytest fixtures reused across all phase test modules.
"""
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass, field
from typing import Optional
import pytest
# Headless Pygame for GUI tests
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
# ---------------------------------------------------------------------------
# Stub domain objects (shared across all phases)
# ---------------------------------------------------------------------------
@dataclass
class StubCard:
name: str
type: str = "creature"
pt: Optional[str] = "2/2"
cmc: int = 3
tapped: bool = False
is_commander: bool = False
oracle: str = ""
def to_dict(self) -> dict:
return {
"name": self.name,
"type": self.type,
"pt": self.pt,
"cmc": self.cmc,
"tapped": self.tapped,
"is_commander": self.is_commander,
"oracle": self.oracle,
}
@dataclass
class StubPlayer:
name: str
seat: int
life: int = 40
eliminated: bool = False
hand: list = field(default_factory=list)
battlefield: list = field(default_factory=list)
graveyard: list = field(default_factory=list)
exile: list = field(default_factory=list)
command_zone: list = field(default_factory=list)
library: list = field(default_factory=list)
commander_damage: dict = field(default_factory=dict)
mana_pool: dict = field(default_factory=dict)
def to_dict(self, private: bool = False) -> dict:
return {
"name": self.name,
"seat": self.seat,
"life": self.life,
"eliminated": self.eliminated,
"hand_count": len(self.hand),
"hand": [c.to_dict() if hasattr(c, 'to_dict') else c for c in self.hand]
if private else [],
"battlefield": [c.to_dict() if hasattr(c, 'to_dict') else c for c in self.battlefield],
"graveyard": [c.to_dict() if hasattr(c, 'to_dict') else c for c in self.graveyard],
"exile": [c.to_dict() if hasattr(c, 'to_dict') else c for c in self.exile],
"command_zone":[c.to_dict() if hasattr(c, 'to_dict') else c for c in self.command_zone],
}
class StubGameState:
def __init__(
self,
num_players: int = 4,
names: Optional[list[str]] = None,
) -> None:
names = names or [f"P{i}" for i in range(num_players)]
self.players = [StubPlayer(name=n, seat=i) for i, n in enumerate(names)]
self.turn = 1
self.current_phase = "main1"
self.active_player_seat = 0
self.stack = []
self.game_over = False
self.winner = None
self.commander_damage: dict[tuple, int] = {}
def get_legal_moves(self, seat: int) -> list[dict]:
return [
{"id": 1, "category": "pass_priority", "description": "Pass priority"},
{"id": 2, "category": "play_land", "description": "Play a land"},
{"id": 3, "category": "cast_spell", "description": "Cast a spell"},
]
def apply_move(self, seat: int, move_id: int) -> None:
pass
def advance_turn(self) -> None:
self.active_player_seat = (self.active_player_seat + 1) % len(self.players)
if self.active_player_seat == 0:
self.turn += 1
# ---------------------------------------------------------------------------
# Pytest fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def game_state():
return StubGameState()
@pytest.fixture
def game_state_2p():
return StubGameState(num_players=2, names=["Alice", "Bob"])
@pytest.fixture
def targeting_memory():
from commander_ai_lab.sim.politics.memory import TargetingMemory
return TargetingMemory(num_players=4)
@pytest.fixture
def comms_channel():
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
return PoliticsCommsChannel()
@pytest.fixture
def politics_engine(targeting_memory, comms_channel):
from commander_ai_lab.sim.politics.engine import PoliticsEngine
return PoliticsEngine(
num_players=4,
memory=targeting_memory,
comms=comms_channel,
personalities={0: "timmy", 1: "spike", 2: "johnny", 3: "aggressive"},
threat_fn=lambda s: {i: 0.3 for i in range(4) if i != s},
)
@pytest.fixture
def threat_assessor():
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
gs = StubGameState()
return ThreatAssessor(game_state=gs, num_players=4)
@pytest.fixture
def fresh_app():
"""FastAPI test app with a clean session store."""
from commander_ai_lab.web.session_store import SessionStore
SessionStore._instance = None
from commander_ai_lab.web.app import create_app
return create_app()
@pytest.fixture
def http_client(fresh_app):
from fastapi.testclient import TestClient
return TestClient(fresh_app)
@pytest.fixture
def event_loop():
"""Provide a fresh event loop for async tests."""
loop = asyncio.new_event_loop()
yield loop
loop.close()
View File
Whitespace-only changes.
-97
View File
@@ -1,97 +0,0 @@
"""Seeded simulation tests for the game engine."""
import random
import pytest
from commander_ai_lab.sim.models import Card, Player, PlayerStats
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.sim.engine import GameEngine
def _make_deck(seed: int, size: int = 60) -> list[Card]:
"""Build a deterministic test deck using a seeded RNG."""
rng = random.Random(seed)
card_pool = [
Card(name="Forest"),
Card(name="Sol Ring"),
Card(name="Cultivate"),
Card(name="Murder"),
Card(name="Wrath of God"),
Card(name="Grizzly Bears"),
Card(name="Serra Angel"),
]
for c in card_pool:
enrich_card(c)
deck = []
for i in range(size):
deck.append(card_pool[i % len(card_pool)].clone())
rng.shuffle(deck)
return deck
class TestGameEngineTwoPlayer:
def test_game_completes(self):
"""A 2-player seeded game should finish without error."""
engine = GameEngine(seed=42)
deck_a = _make_deck(seed=1)
deck_b = _make_deck(seed=2)
result = engine.run_game(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
player_a_name="Alice",
player_b_name="Bob",
)
assert result is not None
assert result.turns > 0
def test_result_has_winner_or_draw(self):
"""winner_seat must be 0, 1, or -1 (draw)."""
engine = GameEngine(seed=99)
deck = _make_deck(seed=5)
result = engine.run_game(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
player_a_name="P1",
player_b_name="P2",
)
assert result.winner_seat in (-1, 0, 1)
def test_seeded_game_is_deterministic(self):
"""Same seed must produce identical results."""
deck_a = _make_deck(seed=7)
deck_b = _make_deck(seed=8)
engine1 = GameEngine(seed=42)
result1 = engine1.run_game(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
player_a_name="Alice",
player_b_name="Bob",
)
engine2 = GameEngine(seed=42)
result2 = engine2.run_game(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
player_a_name="Alice",
player_b_name="Bob",
)
assert result1.winner_seat == result2.winner_seat
assert result1.turns == result2.turns
def test_to_dict_serializable(self):
"""Game result must serialize to a dict with expected keys."""
import json
engine = GameEngine(seed=77)
deck = _make_deck(seed=3)
result = engine.run_game(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
player_a_name="A",
player_b_name="B",
)
d = result.to_dict()
assert "winner" in d
assert "turns" in d
assert "playerResults" in d
# Ensure it's JSON-serializable
json.dumps(d)
-214
View File
@@ -1,214 +0,0 @@
"""Tests for commander_ai_lab.sim.models."""
import pytest
from commander_ai_lab.sim.models import (
Card, Player, PlayerStats, PlayerResult, GameResult, SimState
)
# ── Card ────────────────────────────────────────────────────
class TestCardGetPower:
def test_from_power_field(self):
c = Card(name="Test", power="3", toughness="2", pt="3/2")
assert c.get_power() == 3
def test_from_pt_field(self):
c = Card(name="Test", pt="4/5")
assert c.get_power() == 4
def test_no_pt_returns_zero(self):
c = Card(name="Test")
assert c.get_power() == 0
def test_non_numeric_returns_zero(self):
c = Card(name="Test", power="*", pt="*/2")
assert c.get_power() == 0
class TestCardGetToughness:
def test_from_toughness_field(self):
c = Card(name="Test", power="2", toughness="5", pt="2/5")
assert c.get_toughness() == 5
def test_from_pt_field(self):
c = Card(name="Test", pt="2/7")
assert c.get_toughness() == 7
def test_no_pt_returns_zero(self):
c = Card(name="Test")
assert c.get_toughness() == 0
class TestCardHasKeyword:
def test_in_keywords_list(self):
c = Card(name="Test", keywords=["Flying", "Haste"])
assert c.has_keyword("flying") is True
assert c.has_keyword("haste") is True
def test_in_oracle_text(self):
c = Card(name="Test", oracle_text="This creature has flying and deathtouch.")
assert c.has_keyword("flying") is True
assert c.has_keyword("deathtouch") is True
def test_missing_keyword(self):
c = Card(name="Test", oracle_text="Draw a card.", keywords=[])
assert c.has_keyword("flying") is False
def test_case_insensitive(self):
c = Card(name="Test", keywords=["FLYING"])
assert c.has_keyword("Flying") is True
class TestCardIsLand:
def test_basic_land(self):
c = Card(name="Forest", type_line="Basic Land — Forest")
assert c.is_land() is True
def test_nonbasic_land(self):
c = Card(name="Command Tower", type_line="Land")
assert c.is_land() is True
def test_creature_not_land(self):
c = Card(name="Grizzly Bears", type_line="Creature — Bear")
assert c.is_land() is False
def test_no_type_line(self):
c = Card(name="Unknown")
assert c.is_land() is False
class TestCardIsCreature:
def test_creature(self):
c = Card(name="Grizzly Bears", type_line="Creature — Bear")
assert c.is_creature() is True
def test_legendary_creature(self):
c = Card(name="Korvold", type_line="Legendary Creature — Dragon Noble")
assert c.is_creature() is True
def test_instant_not_creature(self):
c = Card(name="Murder", type_line="Instant")
assert c.is_creature() is False
def test_land_not_creature(self):
c = Card(name="Forest", type_line="Basic Land — Forest")
assert c.is_creature() is False
class TestCardClone:
def test_clone_is_equal(self, basic_creature):
clone = basic_creature.clone()
assert clone.name == basic_creature.name
assert clone.cmc == basic_creature.cmc
def test_clone_is_independent(self, basic_creature):
clone = basic_creature.clone()
clone.name = "Changed"
assert basic_creature.name == "Grizzly Bears"
# ── PlayerStats ─────────────────────────────────────────────
class TestPlayerStats:
def test_to_dict_keys(self):
stats = PlayerStats(cards_drawn=7, damage_dealt=10)
d = stats.to_dict()
assert "cardsDrawn" in d
assert "damageDealt" in d
assert d["cardsDrawn"] == 7
assert d["damageDealt"] == 10
def test_default_values(self):
stats = PlayerStats()
assert stats.cards_drawn == 7
assert stats.lands_played == 0
# ── PlayerResult & GameResult ────────────────────────────────
class TestPlayerResult:
def test_to_dict_winner(self):
pr = PlayerResult(seat_index=0, name="Alice", life=40, finish_position=1)
d = pr.to_dict()
assert d["isWinner"] is True
assert d["seatIndex"] == 0
assert d["name"] == "Alice"
def test_to_dict_loser(self):
pr = PlayerResult(seat_index=1, name="Bob", life=0, eliminated=True, finish_position=2)
d = pr.to_dict()
assert d["isWinner"] is False
assert d["eliminated"] is True
class TestGameResult:
def _make_result(self):
p0 = PlayerResult(seat_index=0, name="Alice", life=35, finish_position=1, stats=PlayerStats(damage_dealt=20))
p1 = PlayerResult(seat_index=1, name="Bob", life=0, eliminated=True, finish_position=2, stats=PlayerStats())
return GameResult(winner_seat=0, turns=12, players=[p0, p1])
def test_winner_property(self):
gr = self._make_result()
assert gr.winner == 0
def test_player_a_name(self):
gr = self._make_result()
assert gr.player_a_name == "Alice"
def test_player_b_name(self):
gr = self._make_result()
assert gr.player_b_name == "Bob"
def test_player_a_eliminated(self):
gr = self._make_result()
assert gr.player_a_eliminated is False
def test_player_b_eliminated(self):
gr = self._make_result()
assert gr.player_b_eliminated is True
def test_to_dict_has_legacy_keys(self):
gr = self._make_result()
d = gr.to_dict()
assert "winner" in d
assert "turns" in d
assert "playerResults" in d
assert len(d["playerResults"]) == 2
# ── SimState ─────────────────────────────────────────────────
class TestSimState:
def test_init_battlefields(self):
state = SimState()
state.init_battlefields(4)
assert len(state.battlefields) == 4
def test_add_and_get_battlefield(self):
state = SimState()
c = Card(name="Test", id=1)
state.add_to_battlefield(0, c)
bf = state.get_battlefield(0)
assert len(bf) == 1
assert bf[0].name == "Test"
def test_remove_from_battlefield(self):
state = SimState()
c = Card(name="Test", id=42)
state.add_to_battlefield(0, c)
removed = state.remove_from_battlefield(42)
assert removed is not None
assert removed.name == "Test"
assert len(state.get_battlefield(0)) == 0
def test_remove_nonexistent_returns_none(self):
state = SimState()
assert state.remove_from_battlefield(999) is None
def test_all_battlefield_cards(self):
state = SimState()
state.add_to_battlefield(0, Card(name="A", id=1))
state.add_to_battlefield(1, Card(name="B", id=2))
state.add_to_battlefield(1, Card(name="C", id=3))
all_cards = state.all_battlefield_cards()
assert len(all_cards) == 3
-189
View File
@@ -1,189 +0,0 @@
"""Tests for commander_ai_lab.sim.rules."""
import pytest
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.rules import enrich_card, score_card, parse_decklist, AI_DEFAULT_WEIGHTS
# ── enrich_card ──────────────────────────────────────────────
class TestEnrichCardBasicLands:
@pytest.mark.parametrize("name,expected_color", [
("Forest", "G"),
("Island", "U"),
("Plains", "W"),
("Swamp", "B"),
("Mountain", "R"),
])
def test_basic_land_color(self, name, expected_color):
c = Card(name=name)
enrich_card(c)
assert c.is_land() is True
assert expected_color in c.oracle_text
assert c.cmc == 0
class TestEnrichCardKnownCards:
def test_sol_ring(self):
c = Card(name="Sol Ring")
enrich_card(c)
assert c.is_ramp is True
assert c.cmc == 1
def test_murder(self):
c = Card(name="Murder")
enrich_card(c)
assert c.is_removal is True
assert c.cmc == 3
def test_wrath_of_god(self):
c = Card(name="Wrath of God")
enrich_card(c)
assert c.is_board_wipe is True
def test_cultivate(self):
c = Card(name="Cultivate")
enrich_card(c)
assert c.is_ramp is True
assert c.cmc == 3
def test_counterspell(self):
c = Card(name="Counterspell")
enrich_card(c)
assert c.is_removal is True
class TestEnrichCardDeterministic:
def test_same_unknown_card_same_cmc(self):
"""Unknown cards must get deterministic CMC (no random jitter)."""
c1 = Card(name="Unknown Foobar Card")
c2 = Card(name="Unknown Foobar Card")
enrich_card(c1)
enrich_card(c2)
assert c1.cmc == c2.cmc
assert c1.pt == c2.pt
def test_different_unknown_cards_may_differ(self):
c1 = Card(name="Aaaaa")
c2 = Card(name="Zzzzz")
enrich_card(c1)
enrich_card(c2)
# At minimum they are both valid creatures with cmc in [2,6]
assert 2 <= c1.cmc <= 6
assert 2 <= c2.cmc <= 6
def test_unknown_card_cmc_range(self):
for name in ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]:
c = Card(name=name)
enrich_card(c)
assert 2 <= c.cmc <= 6
class TestEnrichCardPreservesExistingData:
def test_real_data_not_overwritten(self):
c = Card(name="Custom Card", type_line="Legendary Creature", cmc=7, pt="6/6")
enrich_card(c)
assert c.cmc == 7
assert c.pt == "6/6"
class TestEnrichCardOracleFlags:
def test_removal_flag_from_oracle(self):
c = Card(name="Custom Removal", type_line="Instant", cmc=2,
oracle_text="Destroy target creature.")
enrich_card(c)
assert c.is_removal is True
def test_board_wipe_flag_from_oracle(self):
c = Card(name="Custom Wipe", type_line="Sorcery", cmc=5,
oracle_text="Destroy all creatures.")
enrich_card(c)
assert c.is_board_wipe is True
def test_ramp_flag_from_oracle(self):
c = Card(name="Custom Ramp", type_line="Sorcery", cmc=2,
oracle_text="Search your library for a basic land and put it onto the battlefield.")
enrich_card(c)
assert c.is_ramp is True
# ── score_card ───────────────────────────────────────────────
class TestScoreCard:
def test_land_scores_lower_than_creature(self):
land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0)
creature = Card(name="Grizzly Bears", type_line="Creature — Bear", cmc=2, pt="2/2")
enrich_card(land)
enrich_card(creature)
# Creature with pt bonus should score higher than a plain land
assert score_card(creature) > score_card(land)
def test_flying_bonus(self):
base = Card(name="Vanilla", type_line="Creature", cmc=3, pt="3/3")
flyer = Card(name="Flyer", type_line="Creature", cmc=3, pt="3/3",
oracle_text="Flying", keywords=["Flying"])
assert score_card(flyer) > score_card(base)
def test_ramp_bonus(self):
ramp = Card(name="Sol Ring", type_line="Artifact", cmc=1, is_ramp=True)
plain = Card(name="Mox Nothing", type_line="Artifact", cmc=1)
assert score_card(ramp) > score_card(plain)
def test_removal_bonus(self):
removal = Card(name="Murder", type_line="Instant", cmc=3, is_removal=True,
oracle_text="Destroy target creature.")
plain = Card(name="Shock", type_line="Instant", cmc=1)
assert score_card(removal) > score_card(plain)
def test_multiplayer_scales_flying(self):
flyer = Card(name="Flyer", type_line="Creature", cmc=3, pt="3/3",
oracle_text="Flying", keywords=["Flying"])
score_1v1 = score_card(flyer, num_opponents=1)
score_4p = score_card(flyer, num_opponents=3)
assert score_4p > score_1v1
def test_defender_penalty(self):
defender = Card(name="Wall", type_line="Creature", cmc=2, pt="0/5",
oracle_text="Defender", keywords=["Defender"])
vanilla = Card(name="Vanilla", type_line="Creature", cmc=2, pt="2/2")
assert score_card(defender) < score_card(vanilla)
def test_high_cmc_penalized(self):
cheap = Card(name="Cheap", type_line="Creature", cmc=1, pt="1/1")
expensive = Card(name="Expensive", type_line="Creature", cmc=8, pt="1/1")
assert score_card(cheap) > score_card(expensive)
# ── parse_decklist ───────────────────────────────────────────
class TestParseDecklist:
def test_numbered_entries(self):
text = "4 Lightning Bolt\n2 Counterspell\n1 Black Lotus"
cards = parse_decklist(text)
assert len(cards) == 7
assert cards[0].name == "Lightning Bolt"
def test_unnumbered_entries(self):
cards = parse_decklist("Lightning Bolt")
assert len(cards) == 1
assert cards[0].name == "Lightning Bolt"
def test_skips_comments(self):
text = "// This is a comment\n1 Sol Ring\n# Also a comment\n1 Forest"
cards = parse_decklist(text)
assert len(cards) == 2
def test_skips_blank_lines(self):
cards = parse_decklist("1 Sol Ring\n\n\n1 Forest")
assert len(cards) == 2
def test_returns_card_objects(self):
cards = parse_decklist("1 Sol Ring")
from commander_ai_lab.sim.models import Card
assert isinstance(cards[0], Card)
def test_empty_input(self):
assert parse_decklist("") == []
def test_multi_word_card_names(self):
cards = parse_decklist("1 Kodama's Reach")
assert cards[0].name == "Kodama's Reach"
-301
View File
@@ -1,301 +0,0 @@
"""
Commander AI Lab — AI Benchmarking Suite (Phase 7)
===================================================
Measures AI decision throughput and quality metrics:
BenchThreatAssessor — threat assessment calls per second
BenchPoliticsEngine — deal proposal + evaluation throughput
BenchTargetingMemory — record + query throughput at scale
BenchIntegrated — end-to-end simulated turns / second
All tests use wall-clock timing via time.perf_counter().
Thresholds are conservative to pass on CI hardware.
Run with: pytest tests/test_benchmark.py -v -s
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from typing import Optional
import pytest
# ---------------------------------------------------------------------------
# Shared stubs
# ---------------------------------------------------------------------------
@dataclass
class _Player:
name: str
seat: int
life: int = 40
eliminated: bool = False
hand: list = field(default_factory=list)
battlefield: list = field(default_factory=list)
graveyard: list = field(default_factory=list)
exile: list = field(default_factory=list)
command_zone: list = field(default_factory=list)
library: list = field(default_factory=list)
class _GS:
def __init__(self, n=4):
self.players = [_Player(f"P{i}", i) for i in range(n)]
self.turn = 1
self.current_phase = "main1"
self.active_player_seat = 0
self.stack = []
self.game_over = False
self.winner = None
def get_legal_moves(self, seat):
return [{"id": 1, "category": "pass_priority", "description": "Pass"}]
def apply_move(self, seat, move_id):
pass
def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# 1. ThreatAssessor throughput
# ---------------------------------------------------------------------------
class BenchThreatAssessor:
ITERATIONS = 2000
MIN_OPS_PER_SEC = 500
def test_assess_threats_throughput(self):
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
gs = _GS()
ta = ThreatAssessor(game_state=gs, num_players=4)
start = time.perf_counter()
for _ in range(self.ITERATIONS):
for seat in range(4):
ta.assess_threats(viewer_seat=seat)
elapsed = time.perf_counter() - start
ops = self.ITERATIONS * 4
ops_per_sec = ops / elapsed
print(f"\nThreatAssessor: {ops_per_sec:.0f} assess_threats/s ({elapsed*1000:.1f} ms total)")
assert ops_per_sec >= self.MIN_OPS_PER_SEC, (
f"assess_threats too slow: {ops_per_sec:.0f} ops/s < {self.MIN_OPS_PER_SEC}"
)
def test_top_threat_throughput(self):
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
gs = _GS()
ta = ThreatAssessor(game_state=gs, num_players=4)
start = time.perf_counter()
for _ in range(self.ITERATIONS):
ta.top_threat(viewer_seat=0)
elapsed = time.perf_counter() - start
ops_per_sec = self.ITERATIONS / elapsed
print(f"\nThreatAssessor.top_threat: {ops_per_sec:.0f} ops/s")
assert ops_per_sec >= self.MIN_OPS_PER_SEC
# ---------------------------------------------------------------------------
# 2. TargetingMemory throughput
# ---------------------------------------------------------------------------
class BenchTargetingMemory:
RECORD_COUNT = 10_000
MIN_REC_PER_S = 50_000
MIN_QRY_PER_S = 5_000
def test_record_throughput(self):
from commander_ai_lab.sim.politics.memory import TargetingMemory, ActionType
mem = TargetingMemory(num_players=4)
start = time.perf_counter()
for i in range(self.RECORD_COUNT):
actor = i % 4
target = (i + 1) % 4
mem.record(turn=i // 4, actor=actor, target=target,
action_type=ActionType.ATTACKED)
elapsed = time.perf_counter() - start
rps = self.RECORD_COUNT / elapsed
print(f"\nTargetingMemory.record: {rps:.0f} records/s")
assert rps >= self.MIN_REC_PER_S
def test_aggression_query_throughput(self):
from commander_ai_lab.sim.politics.memory import TargetingMemory, ActionType
mem = TargetingMemory(num_players=4)
for i in range(1000):
mem.record(i // 4, i % 4, (i+1) % 4, ActionType.ATTACKED)
start = time.perf_counter()
for turn in range(self.RECORD_COUNT // 10):
mem.aggression_score(0, 1, current_turn=turn)
elapsed = time.perf_counter() - start
qps = (self.RECORD_COUNT // 10) / elapsed
print(f"\nTargetingMemory.aggression_score: {qps:.0f} queries/s")
assert qps >= self.MIN_QRY_PER_S
def test_summary_matrix_correctness(self):
from commander_ai_lab.sim.politics.memory import TargetingMemory, ActionType
mem = TargetingMemory(num_players=4)
mem.record(1, 0, 1, ActionType.ATTACKED)
mem.record(1, 2, 3, ActionType.REMOVED_PERMANENT)
summary = mem.summary(current_turn=2)
assert (0, 1) in summary
assert (2, 3) in summary
assert summary[(0, 1)] > 0
assert summary[(2, 3)] > 0
# ---------------------------------------------------------------------------
# 3. PoliticsEngine throughput
# ---------------------------------------------------------------------------
class BenchPoliticsEngine:
PROPOSALS = 200
MIN_PROPOSALS_PER_S = 100
def test_deal_proposal_throughput(self):
from commander_ai_lab.sim.politics.memory import TargetingMemory
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
from commander_ai_lab.sim.politics.engine import PoliticsEngine
from commander_ai_lab.sim.politics.deals import DealType
mem = TargetingMemory(4)
ch = PoliticsCommsChannel()
pe = PoliticsEngine(
4, mem, ch,
{0:"timmy",1:"spike",2:"johnny",3:"aggressive"},
lambda s: {i:0.3 for i in range(4) if i!=s},
)
async def run_proposals():
start = time.perf_counter()
for i in range(self.PROPOSALS):
await pe.propose_deal(
proposer=i % 4,
target=(i + 1) % 4,
deal_type=DealType.NON_AGGRESSION,
current_turn=i,
)
return time.perf_counter() - start
elapsed = _run(run_proposals())
pps = self.PROPOSALS / elapsed
print(f"\nPoliticsEngine.propose_deal: {pps:.0f} proposals/s")
assert pps >= self.MIN_PROPOSALS_PER_S
def test_check_violation_throughput(self):
from commander_ai_lab.sim.politics.memory import TargetingMemory, ActionType
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
from commander_ai_lab.sim.politics.engine import PoliticsEngine
from commander_ai_lab.sim.politics.deals import DealType, DealResponse
mem = TargetingMemory(4)
ch = PoliticsCommsChannel()
pe = PoliticsEngine(4, mem, ch,
{0:"timmy",1:"spike",2:"johnny",3:"aggressive"},
lambda s: {i:0.3 for i in range(4) if i!=s})
# Pre-populate 50 active deals
async def setup():
for i in range(50):
d = await pe.propose_deal(i%4, (i+1)%4, DealType.NON_AGGRESSION, current_turn=1)
d.response = DealResponse.ACCEPTED
_run(setup())
start = time.perf_counter()
for _ in range(1000):
pe.check_deal_violation(1, 0, ActionType.ATTACKED, current_turn=2)
elapsed = time.perf_counter() - start
cps = 1000 / elapsed
print(f"\nPoliticsEngine.check_deal_violation: {cps:.0f} checks/s")
assert cps >= 500
# ---------------------------------------------------------------------------
# 4. Integrated simulated-turn throughput
# ---------------------------------------------------------------------------
class BenchIntegrated:
SIMULATED_TURNS = 50
MIN_TURNS_PER_S = 10
def test_simulated_turn_throughput(self):
"""
Simulate N full turns: threat assessment + politics check + memory record.
Measures combined per-turn overhead of the AI subsystems.
"""
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
from commander_ai_lab.sim.politics.memory import TargetingMemory, ActionType
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
from commander_ai_lab.sim.politics.engine import PoliticsEngine
from commander_ai_lab.sim.politics.deals import DealType
gs = _GS()
ta = ThreatAssessor(game_state=gs, num_players=4)
mem = TargetingMemory(4)
ch = PoliticsCommsChannel()
pe = PoliticsEngine(
4, mem, ch,
{0:"timmy",1:"spike",2:"johnny",3:"aggressive"},
lambda s: ta.assess_threats(viewer_seat=s),
)
async def simulate():
start = time.perf_counter()
for turn in range(self.SIMULATED_TURNS):
active = turn % 4
# Assess threats for all seats
for seat in range(4):
ta.assess_threats(viewer_seat=seat)
# Record a simulated attack
attacker = active
defender = (active + 1) % 4
mem.record_attack(turn, attacker, defender)
mem.record_damage(turn, attacker, defender, amount=5)
# Politics: on_turn_start
await pe.on_turn_start(
active_seat=active,
current_turn=turn,
game_state=gs,
)
return time.perf_counter() - start
elapsed = _run(simulate())
tps = self.SIMULATED_TURNS / elapsed
print(f"\nIntegrated simulated turns: {tps:.1f} turns/s ({elapsed*1000:.0f} ms total)")
assert tps >= self.MIN_TURNS_PER_S
# ---------------------------------------------------------------------------
# 5. Memory scalability (large event log)
# ---------------------------------------------------------------------------
class BenchMemoryScale:
def test_aggression_score_scales_linearly(self):
"""
Verify O(n) query time doesn't blow up with 100k events.
"""
from commander_ai_lab.sim.politics.memory import TargetingMemory, ActionType
mem = TargetingMemory(4)
N = 100_000
for i in range(N):
mem.record(i, i % 4, (i+1) % 4, ActionType.ATTACKED)
start = time.perf_counter()
mem.aggression_score(0, 1, current_turn=N)
elapsed = time.perf_counter() - start
print(f"\naggression_score with {N} events: {elapsed*1000:.2f} ms")
assert elapsed < 1.0, f"Too slow with {N} events: {elapsed:.3f}s"
-408
View File
@@ -1,408 +0,0 @@
"""
Commander AI Lab — Integration Tests (Phase 7)
===============================================
End-to-end tests that wire multiple subsystems together:
- ThreatAssessor + PoliticsEngine
- PoliticsEngine + TargetingMemory + CommsChannel
- REST API + SessionStore + WebSocket
- Turn lifecycle: phase transitions, AI decision flow, elimination
Run with: pytest tests/test_integration.py -v
"""
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass, field
from typing import Optional
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
@dataclass
class _Player:
name: str
seat: int
life: int = 40
eliminated: bool = False
hand: list = field(default_factory=list)
battlefield: list = field(default_factory=list)
graveyard: list = field(default_factory=list)
exile: list = field(default_factory=list)
command_zone: list = field(default_factory=list)
library: list = field(default_factory=list)
class _GS:
def __init__(self, n=4):
self.players = [_Player(f"P{i}", i) for i in range(n)]
self.turn = 1
self.current_phase = "main1"
self.active_player_seat = 0
self.stack = []
self.game_over = False
self.winner = None
def get_legal_moves(self, seat):
return [{"id": 1, "category": "pass_priority", "description": "Pass"}]
def apply_move(self, seat, move_id):
pass
# ---------------------------------------------------------------------------
# 1. ThreatAssessor + PoliticsEngine integration
# ---------------------------------------------------------------------------
class TestThreatPoliticsIntegration:
"""
Verify that ThreatAssessor scores flow into PoliticsEngine correctly
and that the adjusted_threat_score incorporates spite and memory.
"""
def _build(self):
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
from commander_ai_lab.sim.politics.memory import TargetingMemory
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
from commander_ai_lab.sim.politics.engine import PoliticsEngine
gs = _GS()
ta = ThreatAssessor(game_state=gs, num_players=4)
mem = TargetingMemory(num_players=4)
ch = PoliticsCommsChannel()
pe = PoliticsEngine(
num_players=4,
memory=mem,
comms=ch,
personalities={0:"timmy",1:"spike",2:"johnny",3:"aggressive"},
threat_fn=lambda s: ta.assess_threats(viewer_seat=s),
)
return gs, ta, mem, ch, pe
def test_adjusted_threat_no_spite_equals_base(self):
gs, ta, mem, ch, pe = self._build()
base = ta.assess_threats(viewer_seat=0).get(1, 0.0)
adjusted = pe.adjusted_threat_score(viewer=0, target=1, current_turn=1)
# Without spite or memory events, adjusted ≈ base (may differ by mem term)
assert abs(adjusted - base) < 0.35
def test_spite_raises_adjusted_score(self):
gs, ta, mem, ch, pe = self._build()
before = pe.adjusted_threat_score(0, 1, 1)
pe._add_spite(wronged_seat=0, target_seat=1, current_turn=1, intensity=0.5)
after = pe.adjusted_threat_score(0, 1, 1)
assert after > before
def test_memory_aggression_raises_adjusted_score(self):
from commander_ai_lab.sim.politics.memory import ActionType
gs, ta, mem, ch, pe = self._build()
before = pe.adjusted_threat_score(0, 2, 1)
for _ in range(5):
mem.record(1, 2, 0, ActionType.ATTACKED)
after = pe.adjusted_threat_score(0, 2, 1)
assert after > before
def test_top_threat_matches_assessor(self):
gs, ta, mem, ch, pe = self._build()
# Give seat 3 a huge life lead to inflate threat
gs.players[3].life = 5 # low life — looks like they need a kill
threats = ta.assess_threats(viewer_seat=0)
# Just confirm assess_threats returns 3 keys for a 4-player game
assert len(threats) == 3
# ---------------------------------------------------------------------------
# 2. Politics full cycle: propose → accept → violate → spite
# ---------------------------------------------------------------------------
class TestPoliticsFullCycle:
def test_deal_cycle_end_to_end(self):
from commander_ai_lab.sim.politics.deals import DealType, DealResponse
from commander_ai_lab.sim.politics.memory import TargetingMemory, ActionType
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
from commander_ai_lab.sim.politics.engine import PoliticsEngine
mem = TargetingMemory(4)
ch = PoliticsCommsChannel()
pe = PoliticsEngine(
4, mem, ch,
personalities={0:"combo",1:"control",2:"timmy",3:"spike"},
threat_fn=lambda s: {i:0.4 for i in range(4) if i!=s},
)
# Propose
deal = run(pe.propose_deal(0, 1, DealType.NON_AGGRESSION, current_turn=2))
assert deal.deal_id in pe._deals
# Force accept for determinism
deal.response = DealResponse.ACCEPTED
# Violate: seat 1 attacks seat 0
broken = pe.check_deal_violation(
actor=1, action_target=0,
action_type=ActionType.ATTACKED,
current_turn=3,
)
assert len(broken) == 1
assert broken[0].response == DealResponse.BROKEN
# Spite applied
assert pe.get_spite_bias(0, 1, 3) > 0
# Memory recorded breach
score = mem.aggression_score(1, 0, current_turn=3)
assert score > 0
def test_comms_log_has_proposal_and_response(self):
from commander_ai_lab.sim.politics.deals import DealType
from commander_ai_lab.sim.politics.memory import TargetingMemory
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
from commander_ai_lab.sim.politics.engine import PoliticsEngine
mem = TargetingMemory(4)
ch = PoliticsCommsChannel()
pe = PoliticsEngine(4, mem, ch,
{0:"timmy",1:"spike",2:"johnny",3:"aggressive"},
lambda s: {i:0.3 for i in range(4) if i!=s})
run(pe.propose_deal(0, 2, DealType.SPARE_THIS_TURN, current_turn=4))
assert len(ch._log) >= 2
speakers = {b.speaker for b in ch._log}
assert 0 in speakers # proposer spoke
assert 2 in speakers # responder spoke
# ---------------------------------------------------------------------------
# 3. REST API → SessionStore → State snapshot round-trip
# ---------------------------------------------------------------------------
class TestAPISessionIntegration:
@pytest.fixture
def client(self):
from commander_ai_lab.web.session_store import SessionStore
SessionStore._instance = None
from commander_ai_lab.web.app import create_app
from fastapi.testclient import TestClient
return TestClient(create_app())
def _new_game(self, client, names=None):
names = names or ["Alice", "Timmy", "Spike", "Johnny"]
r = client.post("/api/v1/games", json={
"player_names": names,
"human_seat": 0,
"ai_personality": ["aggressive", "control", "combo"],
})
assert r.status_code == 201
return r.json()["game_id"]
def test_create_then_get_state(self, client):
gid = self._new_game(client)
snap = client.get(f"/api/v1/games/{gid}").json()
assert snap["game_id"] == gid
assert len(snap["players"]) == 4
def test_player_names_preserved(self, client):
gid = self._new_game(client, ["Alice","Bob","Carol","Dave"])
snap = client.get(f"/api/v1/games/{gid}").json()
names = [p["name"] for p in snap["players"]]
assert names == ["Alice", "Bob", "Carol", "Dave"]
def test_starting_life_40(self, client):
gid = self._new_game(client)
snap = client.get(f"/api/v1/games/{gid}").json()
for p in snap["players"]:
assert p["life"] == 40
def test_move_accepted(self, client):
gid = self._new_game(client)
moves = client.get(f"/api/v1/games/{gid}/moves?seat=0").json()["moves"]
r = client.post(f"/api/v1/games/{gid}/move",
json={"seat": 0, "move_id": moves[0]["id"]})
assert r.json()["accepted"] is True
def test_concede_marks_eliminated(self, client):
gid = self._new_game(client)
client.post(f"/api/v1/games/{gid}/concede", json={"seat": 3})
snap = client.get(f"/api/v1/games/{gid}").json()
assert snap["players"][3]["eliminated"] is True
def test_delete_then_404(self, client):
gid = self._new_game(client)
client.delete(f"/api/v1/games/{gid}")
r = client.get(f"/api/v1/games/{gid}")
assert r.status_code == 404
def test_multiple_games_isolated(self, client):
gid1 = self._new_game(client, ["A","B","C","D"])
gid2 = self._new_game(client, ["W","X","Y","Z"])
assert gid1 != gid2
snap1 = client.get(f"/api/v1/games/{gid1}").json()
snap2 = client.get(f"/api/v1/games/{gid2}").json()
assert snap1["players"][0]["name"] == "A"
assert snap2["players"][0]["name"] == "W"
# ---------------------------------------------------------------------------
# 4. Turn-manager phase transition integration
# ---------------------------------------------------------------------------
class TestTurnManagerIntegration:
def test_run_game_completes(self):
from commander_ai_lab.sim.turn_manager import CommanderTurnManager, TurnManagerConfig
gs = _GS()
# Make all players except seat 0 eliminated so game ends immediately
for p in gs.players[1:]:
p.eliminated = True
events = []
async def on_event(ev):
events.append(ev)
async def on_thinking(seat, flag):
pass
tm = CommanderTurnManager(
game_state=gs,
ai_opponents=[],
human_seats=set(),
on_event=on_event,
on_thinking=on_thinking,
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=2),
)
asyncio.get_event_loop().run_until_complete(tm.run_game())
# Game should have fired at least one event
assert len(events) >= 1
def test_phase_sequence_order(self):
from commander_ai_lab.sim.turn_manager import CommanderTurnManager, TurnManagerConfig
gs = _GS()
for p in gs.players[1:]:
p.eliminated = True
phases_seen = []
async def on_event(ev):
if hasattr(ev, 'phase') and ev.phase:
phases_seen.append(ev.phase)
async def on_thinking(seat, flag):
pass
tm = CommanderTurnManager(
game_state=gs,
ai_opponents=[],
human_seats=set(),
on_event=on_event,
on_thinking=on_thinking,
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=1),
)
asyncio.get_event_loop().run_until_complete(tm.run_game())
# If phases were emitted, they should start with untap
if phases_seen:
assert phases_seen[0] in ("untap", "upkeep", "draw", "main1")
def test_elimination_ends_game(self):
from commander_ai_lab.sim.turn_manager import CommanderTurnManager, TurnManagerConfig
gs = _GS()
for p in gs.players[1:]:
p.eliminated = True
p.life = 0
game_over_fired = [False]
async def on_event(ev):
if getattr(ev, 'event_type', '') == 'game_over':
game_over_fired[0] = True
async def on_thinking(seat, flag):
pass
tm = CommanderTurnManager(
game_state=gs,
ai_opponents=[],
human_seats=set(),
on_event=on_event,
on_thinking=on_thinking,
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=3),
)
asyncio.get_event_loop().run_until_complete(tm.run_game())
assert game_over_fired[0] is True
# ---------------------------------------------------------------------------
# 5. WebSocket protocol integration (via TestClient)
# ---------------------------------------------------------------------------
class TestWebSocketIntegration:
@pytest.fixture
def ws_client(self):
from commander_ai_lab.web.session_store import SessionStore
SessionStore._instance = None
from commander_ai_lab.web.app import create_app
from fastapi.testclient import TestClient
return TestClient(create_app())
def _create_game(self, client) -> str:
r = client.post("/api/v1/games", json={
"player_names": ["H","AI1","AI2","AI3"],
"human_seat": 0,
"ai_personality": ["aggressive","control","combo"],
})
return r.json()["game_id"]
def test_ws_connect_receives_state(self, ws_client):
gid = self._create_game(ws_client)
with ws_client.websocket_connect(f"/ws/game/{gid}?seat=0") as ws:
data = json.loads(ws.receive_text())
assert data["type"] == "state"
assert "players" in data["data"]
def test_ws_ping_pong(self, ws_client):
gid = self._create_game(ws_client)
with ws_client.websocket_connect(f"/ws/game/{gid}?seat=0") as ws:
ws.receive_text() # consume initial state
ws.send_text(json.dumps({"type": "ping"}))
resp = json.loads(ws.receive_text())
assert resp["type"] == "pong"
def test_ws_invalid_json_returns_error(self, ws_client):
gid = self._create_game(ws_client)
with ws_client.websocket_connect(f"/ws/game/{gid}?seat=0") as ws:
ws.receive_text()
ws.send_text("not json{{{")
resp = json.loads(ws.receive_text())
assert resp["type"] == "error"
def test_ws_unknown_message_type_returns_error(self, ws_client):
gid = self._create_game(ws_client)
with ws_client.websocket_connect(f"/ws/game/{gid}?seat=0") as ws:
ws.receive_text()
ws.send_text(json.dumps({"type": "unknown_cmd"}))
resp = json.loads(ws.receive_text())
assert resp["type"] == "error"
def test_ws_move_accepted(self, ws_client):
gid = self._create_game(ws_client)
with ws_client.websocket_connect(f"/ws/game/{gid}?seat=0") as ws:
ws.receive_text() # initial state
ws.send_text(json.dumps({"type": "move", "seat": 0, "move_id": 1}))
resp = json.loads(ws.receive_text())
# Server should broadcast updated state
assert resp["type"] in ("state", "error")
def test_ws_invalid_game_id_closes(self, ws_client):
with pytest.raises(Exception):
with ws_client.websocket_connect("/ws/game/doesnotexist?seat=0") as ws:
ws.receive_text()
-243
View File
@@ -1,243 +0,0 @@
"""
Phase 1 Unit Tests — Game State, Prompt Builder, State Logger
=============================================================
Run with: pytest tests/test_phase1.py -v
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
import pytest
from commander_ai_lab.sim.models import Card, Player, SimState
from commander_ai_lab.sim.game_state import (
CommanderGameState,
CommanderPlayer,
ManaPool,
StackItem,
)
from commander_ai_lab.sim.prompt_builder import (
state_to_prompt,
legal_moves_to_prompt,
build_full_prompt,
)
from commander_ai_lab.sim.state_logger import StateLogger, MAX_SNAPSHOTS
# ── Fixtures ──────────────────────────────────────────────────
def _make_card(name: str, type_line: str = "Creature", cmc: int = 3) -> Card:
c = Card(name=name, type_line=type_line, cmc=cmc)
c.power = "3"
c.toughness = "3"
return c
def _make_game_state(num_players: int = 2) -> CommanderGameState:
"""Build a minimal CommanderGameState for testing."""
sim = SimState()
sim.init_battlefields(num_players)
for i in range(num_players):
p = Player(name=f"Player{i}", life=40, owner_id=i)
p.hand = [_make_card(f"Card{j}") for j in range(4)]
p.library = [_make_card(f"Lib{j}") for j in range(20)]
sim.players.append(p)
sim.add_to_battlefield(i, _make_card(f"Creature{i}"))
gs = CommanderGameState.from_sim_state(sim)
gs.turn = 3
gs.current_phase = "main1"
gs.active_player_seat = 0
gs.priority_seat = 0
# Give player 0 a commander
commander = _make_card("Atraxa, Praetors' Voice", "Legendary Creature")
commander.is_commander = True
gs.commander_players[0].commander_zone.append(commander)
return gs
# ── ManaPool Tests ────────────────────────────────────────────
class TestManaPool:
def test_total(self):
mp = ManaPool(W=2, U=1, G=3)
assert mp.total() == 6
def test_empty(self):
mp = ManaPool(W=2, U=3)
mp.empty()
assert mp.total() == 0
def test_serialization(self):
mp = ManaPool(W=1, R=2, C=1)
d = mp.to_dict()
assert d["W"] == 1
assert d["total"] == 4
mp2 = ManaPool.from_dict(d)
assert mp2.R == 2
# ── CommanderPlayer Tests ─────────────────────────────────────
class TestCommanderPlayer:
def test_commander_tax(self):
cp = CommanderPlayer(base=Player(name="Test"))
assert cp.commander_tax() == 0
cp.commander_cast_count = 2
assert cp.commander_tax() == 4
def test_commander_damage_death(self):
cp = CommanderPlayer(base=Player(name="Test"))
cp.commander_damage_received = {0: 20, 1: 5}
assert not cp.is_dead_to_commander_damage()
cp.commander_damage_received[0] = 21
assert cp.is_dead_to_commander_damage()
def test_to_dict_json_serializable(self):
cp = CommanderPlayer(base=Player(name="Test", life=35))
cp.commander_zone.append(_make_card("Krenko", "Legendary Creature"))
d = cp.to_dict()
# Must be JSON-serializable
json.dumps(d)
assert d["life"] == 35
assert d["commanderZone"] == ["Krenko"]
# ── CommanderGameState Tests ──────────────────────────────────
class TestCommanderGameState:
def test_from_sim_state(self):
gs = _make_game_state(4)
assert len(gs.commander_players) == 4
assert gs.commander_players[0].name == "Player0"
def test_to_json_is_valid(self):
gs = _make_game_state(2)
raw = gs.to_json()
parsed = json.loads(raw)
assert parsed["turn"] == 3
assert len(parsed["players"]) == 2
def test_commander_damage_tracking(self):
gs = _make_game_state(2)
gs.deal_commander_damage(from_seat=0, to_seat=1, amount=10)
assert gs.get_commander_damage(0, 1) == 10
assert gs.commander_players[1].life == 30
def test_stack_operations(self):
gs = _make_game_state(2)
item = StackItem(item_type="spell", card_name="Counterspell",
controller_seat=1, description="Counter target spell")
gs.stack.append(item)
assert not gs.stack_is_empty()
assert gs.stack[0].card_name == "Counterspell"
# ── Prompt Builder Tests ──────────────────────────────────────
class TestPromptBuilder:
def test_state_to_prompt_contains_player_info(self):
gs = _make_game_state(2)
prompt = state_to_prompt(gs, viewer_seat=0)
assert "Player0" in prompt
assert "Player1" in prompt
assert "Turn: 3" in prompt
assert "Main Phase 1" in prompt
def test_viewer_hand_shown_opponent_hidden(self):
gs = _make_game_state(2)
prompt = state_to_prompt(gs, viewer_seat=0)
# Viewer's hand should list cards; opponent's should say hidden
assert "Card0" in prompt
assert "hidden" in prompt
def test_commander_shown_in_prompt(self):
gs = _make_game_state(2)
prompt = state_to_prompt(gs, viewer_seat=0)
assert "Atraxa" in prompt
def test_legal_moves_numbered(self):
moves = [
{"id": 1, "category": "cast_spell", "description": "Cast Lightning Bolt"},
{"id": 2, "category": "pass_priority", "description": "Pass priority"},
]
result = legal_moves_to_prompt(moves)
assert "1." in result
assert "2." in result
assert "Cast Spell" in result
def test_empty_moves_returns_pass_message(self):
result = legal_moves_to_prompt([])
assert "pass priority" in result.lower()
def test_build_full_prompt_under_token_estimate(self):
gs = _make_game_state(4)
moves = [
{"id": i, "category": "cast_spell",
"description": f"Cast Spell {i}"} for i in range(10)
]
prompt = build_full_prompt(gs, viewer_seat=0, moves=moves,
personality="You are an aggressive player.")
# Rough token estimate: 1 token ~= 4 chars
estimated_tokens = len(prompt) / 4
assert estimated_tokens < 3000, f"Prompt too long: ~{estimated_tokens:.0f} tokens"
# ── State Logger Tests ────────────────────────────────────────
class TestStateLogger:
def test_save_and_load(self, tmp_path):
gs = _make_game_state(2)
moves = [{"id": 1, "category": "pass_priority", "description": "Pass"}]
prompt = build_full_prompt(gs, 0, moves)
logger = StateLogger(log_dir=tmp_path)
path = logger.save(gs, prompt, chosen_move_id=1, seat=0)
assert path.exists()
data = logger.load(path)
assert data["meta"]["turn"] == 3
assert data["meta"]["chosenMoveId"] == 1
assert data["prompt"] == prompt
assert "gameState" in data
def test_rotation_enforces_max(self, tmp_path):
gs = _make_game_state(2)
logger = StateLogger(log_dir=tmp_path)
# Write MAX_SNAPSHOTS + 5 files
for i in range(MAX_SNAPSHOTS + 5):
gs.turn = i
logger.save(gs, f"prompt {i}", chosen_move_id=0, seat=0)
assert logger.count() <= MAX_SNAPSHOTS
def test_load_latest(self, tmp_path):
gs = _make_game_state(2)
logger = StateLogger(log_dir=tmp_path)
gs.turn = 99
logger.save(gs, "latest prompt", chosen_move_id=2, seat=1)
latest = logger.load_latest()
assert latest is not None
assert latest["meta"]["turn"] == 99
def test_clear_all(self, tmp_path):
gs = _make_game_state(2)
logger = StateLogger(log_dir=tmp_path)
for _ in range(5):
logger.save(gs, "prompt", chosen_move_id=0, seat=0)
deleted = logger.clear_all()
assert deleted == 5
assert logger.count() == 0
def test_fallback_flag_recorded(self, tmp_path):
gs = _make_game_state(2)
logger = StateLogger(log_dir=tmp_path)
path = logger.save(gs, "prompt", chosen_move_id=None,
seat=0, fallback_used=True)
data = logger.load(path)
assert data["meta"]["fallbackUsed"] is True
-278
View File
@@ -1,278 +0,0 @@
"""
Phase 2 Unit Tests — AIOpponent, Personality Prompts
=====================================================
Run with: pytest tests/test_phase2.py -v
Note: LLM connectivity is NOT required. All tests run fully offline
using the heuristic fallback path.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import pytest
from commander_ai_lab.sim.models import Card, Player, SimState
from commander_ai_lab.sim.game_state import CommanderGameState
from commander_ai_lab.sim.personality_prompts import (
AGGRO_TIMMY, CONTROL_SPIKE, COMBO_JOHNNY, POLITICAL_NEGOTIATOR,
get_personality, list_personalities, PERSONALITIES,
)
from commander_ai_lab.sim.ai_opponent import AIOpponent, create_four_player_ai_roster
# ── Helpers ────────────────────────────────────────────────────────────
def _make_card(name: str, type_line: str = "Creature", cmc: int = 3) -> Card:
c = Card(name=name, type_line=type_line, cmc=cmc)
c.power = "3"
c.toughness = "3"
return c
def _make_game_state(num_players: int = 4) -> CommanderGameState:
sim = SimState()
sim.init_battlefields(num_players)
for i in range(num_players):
p = Player(name=f"Player{i}", life=40, owner_id=i)
p.hand = [_make_card(f"Card{j}") for j in range(4)]
p.library = [_make_card(f"Lib{j}") for j in range(20)]
sim.players.append(p)
gs = CommanderGameState.from_sim_state(sim)
gs.turn = 5
gs.current_phase = "main1"
gs.active_player_seat = 0
gs.priority_seat = 0
return gs
def _make_moves(n: int = 5) -> list[dict]:
categories = ["play_land", "cast_spell", "attack", "pass_priority", "activate_ability"]
return [
{"id": i + 1, "category": categories[i % len(categories)],
"description": f"Action {i + 1}"}
for i in range(n)
]
def _offline_ai(seat: int = 0, personality=None, tmp_path: Path | None = None) -> AIOpponent:
"""Create an AIOpponent with LLM disconnected (offline/fallback mode)."""
ai = AIOpponent(
name=f"TestAI_{seat}",
seat=seat,
personality=personality or AGGRO_TIMMY,
log_dir=str(tmp_path) if tmp_path else tempfile.mkdtemp(),
)
ai.brain._connected = False # Force offline / heuristic mode
return ai
# ── Personality Tests ────────────────────────────────────────────────
class TestPersonalityPrompts:
def test_all_four_personalities_exist(self):
assert len(PERSONALITIES) == 4
for key in ("aggro_timmy", "control_spike", "combo_johnny", "political_negotiator"):
assert key in PERSONALITIES
def test_get_personality_returns_correct(self):
p = get_personality("combo_johnny")
assert p.key == "combo_johnny"
assert "combo" in p.system_prompt.lower()
def test_get_personality_raises_on_unknown(self):
with pytest.raises(KeyError):
get_personality("does_not_exist")
def test_list_personalities(self):
keys = list_personalities()
assert len(keys) == 4
def test_system_prompts_are_nonempty(self):
for p in PERSONALITIES.values():
assert len(p.system_prompt) > 100
def test_narration_styles_defined(self):
for p in PERSONALITIES.values():
assert p.narration_style
def test_prompt_under_token_budget(self):
# Each personality system_prompt alone must stay under ~2000 tokens (~8000 chars)
for p in PERSONALITIES.values():
assert len(p.system_prompt) < 8000, f"{p.key} system_prompt too long"
# ── AIOpponent Construction Tests ───────────────────────────────────
class TestAIOpponentConstruction:
def test_instantiation(self, tmp_path):
ai = _offline_ai(seat=2, personality=CONTROL_SPIKE, tmp_path=tmp_path)
assert ai.name == "TestAI_2"
assert ai.seat == 2
assert ai.personality.key == "control_spike"
assert ai.brain is not None
assert ai.state_logger is not None
def test_four_player_roster(self, tmp_path):
roster = create_four_player_ai_roster(log_dir=str(tmp_path))
assert len(roster) == 4
seats = [ai.seat for ai in roster]
assert seats == [0, 1, 2, 3]
personalities = [ai.personality.key for ai in roster]
assert len(set(personalities)) == 4 # all different
def test_four_player_roster_custom_names(self, tmp_path):
names = ["Alice", "Bob", "Carol", "Dave"]
roster = create_four_player_ai_roster(names=names, log_dir=str(tmp_path))
for ai, name in zip(roster, names):
assert ai.name == name
def test_new_game_resets_state(self, tmp_path):
ai = _offline_ai(tmp_path=tmp_path)
gs = _make_game_state()
moves = _make_moves()
ai.decide_action(gs, moves)
assert ai._total_decisions == 1
ai.new_game()
assert ai._total_decisions == 0
assert len(ai.memory_log) == 0
# ── decide_action Tests (offline / heuristic) ────────────────────────
class TestDecideAction:
def test_returns_valid_move_id(self, tmp_path):
ai = _offline_ai(tmp_path=tmp_path)
gs = _make_game_state()
moves = _make_moves()
result = ai.decide_action(gs, moves)
valid_ids = {m["id"] for m in moves}
assert result in valid_ids
def test_prefers_play_land(self, tmp_path):
ai = _offline_ai(tmp_path=tmp_path)
gs = _make_game_state()
moves = [
{"id": 1, "category": "pass_priority", "description": "Pass"},
{"id": 2, "category": "play_land", "description": "Play Forest"},
{"id": 3, "category": "cast_spell", "description": "Cast Llanowar Elves"},
]
result = ai.decide_action(gs, moves)
assert result == 2 # play_land has highest priority
def test_empty_moves_returns_none(self, tmp_path):
ai = _offline_ai(tmp_path=tmp_path)
gs = _make_game_state()
result = ai.decide_action(gs, [])
assert result is None
def test_fallback_used_flag_logged(self, tmp_path):
ai = _offline_ai(tmp_path=tmp_path)
gs = _make_game_state()
moves = _make_moves()
ai.decide_action(gs, moves)
assert ai._total_fallbacks == 1 # LLM disconnected → always fallback
def test_memory_log_populated(self, tmp_path):
ai = _offline_ai(tmp_path=tmp_path)
gs = _make_game_state()
moves = _make_moves()
ai.decide_action(gs, moves)
assert len(ai.memory_log) == 1
entry = ai.memory_log[0]
assert entry.turn == 5
assert entry.fallback_used is True
def test_snapshot_saved_to_disk(self, tmp_path):
ai = _offline_ai(tmp_path=tmp_path)
gs = _make_game_state()
moves = _make_moves()
ai.decide_action(gs, moves)
snaps = ai.state_logger.list_snapshots()
assert len(snaps) == 1
def test_multiple_decisions_accumulate(self, tmp_path):
ai = _offline_ai(tmp_path=tmp_path)
gs = _make_game_state()
moves = _make_moves()
for _ in range(5):
ai.decide_action(gs, moves)
assert ai._total_decisions == 5
# ── narrate_play Tests ────────────────────────────────────────────────
class TestNarratePlay:
def test_returns_string(self, tmp_path):
ai = _offline_ai(tmp_path=tmp_path)
moves = _make_moves()
result = ai.narrate_play(1, moves)
assert isinstance(result, str)
assert len(result) > 0
def test_under_30_words(self, tmp_path):
for personality in PERSONALITIES.values():
ai = _offline_ai(personality=personality, tmp_path=tmp_path)
moves = _make_moves()
narration = ai.narrate_play(1, moves)
assert len(narration.split()) <= 30, (
f"Narration too long for {personality.key}: '{narration}'"
)
def test_narration_backfilled_to_memory(self, tmp_path):
ai = _offline_ai(tmp_path=tmp_path)
gs = _make_game_state()
moves = _make_moves()
ai.decide_action(gs, moves)
narration = ai.narrate_play(1, moves)
assert ai.memory_log[-1].narration == narration
def test_none_move_id(self, tmp_path):
ai = _offline_ai(tmp_path=tmp_path)
result = ai.narrate_play(None, [])
assert isinstance(result, str)
# ── parse_move_id Tests ────────────────────────────────────────────────
class TestParseMoveId:
def setup_method(self):
self.ai = _offline_ai()
self.moves = _make_moves(5) # ids 1–5
def test_plain_number(self):
assert self.ai._parse_move_id("3", self.moves) == 3
def test_number_in_sentence(self):
assert self.ai._parse_move_id("I choose action 2 as my move.", self.moves) == 2
def test_json_response(self):
assert self.ai._parse_move_id('{"id": 4}', self.moves) == 4
def test_invalid_number_returns_none(self):
assert self.ai._parse_move_id("99", self.moves) is None
def test_no_number_returns_none(self):
assert self.ai._parse_move_id("I pass.", self.moves) is None
def test_think_block_stripped(self):
raw = "<think>Let me consider...</think>\n2"
assert self.ai._parse_move_id(raw, self.moves) == 2
# ── Stats Tests ────────────────────────────────────────────────────────────
class TestStats:
def test_get_stats_structure(self, tmp_path):
ai = _offline_ai(tmp_path=tmp_path)
gs = _make_game_state()
moves = _make_moves()
ai.decide_action(gs, moves)
stats = ai.get_stats()
assert stats["name"] == "TestAI_0"
assert stats["total_decisions"] == 1
assert stats["total_fallbacks"] == 1
assert stats["fallback_rate"] == 1.0
assert "llm_stats" in stats
-290
View File
@@ -1,290 +0,0 @@
"""
Phase 3 Unit Tests — Turn Manager, Threat Assessor
===================================================
Run with: pytest tests/test_phase3.py -v
All tests run fully offline (no Ollama required). The turn manager is
tested using a mock game state with stub legal_moves / apply_move,
avoiding any dependency on the live rules engine.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Optional
import pytest
from commander_ai_lab.sim.threat_assessor import assess_threats, top_threat, ThreatScore
from commander_ai_lab.sim.turn_manager import (
CommanderTurnManager,
TurnManagerConfig,
GameEvent,
PHASES,
ACTION_PHASES,
)
from commander_ai_lab.sim.ai_opponent import AIOpponent
from commander_ai_lab.sim.personality_prompts import AGGRO_TIMMY, CONTROL_SPIKE
# ── Stub Game State ────────────────────────────────────────────────────────
@dataclass
class _StubPlayer:
name: str
life: int = 40
eliminated: bool = False
hand: list = field(default_factory=list)
library: list = field(default_factory=list)
graveyard: list = field(default_factory=list)
battlefield: list = field(default_factory=list)
class _StubGameState:
"""Minimal CommanderGameState-compatible stub."""
def __init__(self, num_players: int = 4):
self.players = [_StubPlayer(name=f"P{i}", life=40) for i in range(num_players)]
self.turn = 0
self.current_phase = "main1"
self.active_player_seat = 0
self.priority_seat = 0
self._move_counter = 0
def get_legal_moves(self, seat: int) -> list[dict]:
"""Always return one pass move per seat."""
return [
{"id": 1, "category": "pass_priority", "description": "Pass"},
{"id": 2, "category": "play_land", "description": "Play Forest"},
]
def apply_move(self, seat: int, move_id: int) -> None:
self._move_counter += 1
def _stub_gs(num_players: int = 4) -> _StubGameState:
return _StubGameState(num_players=num_players)
def _offline_ai(seat: int, personality=None) -> AIOpponent:
import tempfile
ai = AIOpponent(
name=f"AI_{seat}",
seat=seat,
personality=personality or AGGRO_TIMMY,
log_dir=tempfile.mkdtemp(),
)
ai.brain._connected = False
return ai
def _make_tm(
num_players: int = 4,
max_turns: int = 2,
human_seats: set | None = None,
) -> CommanderTurnManager:
gs = _stub_gs(num_players)
ai_list = [_offline_ai(i) for i in range(num_players)]
cfg = TurnManagerConfig(max_turns=max_turns, ai_decision_delay=0.0)
return CommanderTurnManager(
game_state=gs,
ai_opponents=ai_list,
human_seats=human_seats or set(),
config=cfg,
)
# ── ThreatAssessor Tests ────────────────────────────────────────────────────
class TestThreatAssessor:
def _gs_with_creatures(self) -> _StubGameState:
gs = _stub_gs(4)
# Give seat 0 a big creature
gs.players[0].battlefield = [
{"name": "Dragon", "type": "creature", "pt": "6/6", "oracle": ""},
{"name": "Forest", "type": "land", "pt": "", "oracle": ""},
]
gs.players[1].hand = [{"name": f"Card{i}"} for i in range(7)]
return gs
def test_returns_score_per_non_eliminated_player(self):
gs = _stub_gs(4)
scores = assess_threats(gs, viewer_seat=0)
assert len(scores) == 4
def test_eliminated_players_excluded(self):
gs = _stub_gs(4)
gs.players[2].eliminated = True
scores = assess_threats(gs, viewer_seat=0)
assert len(scores) == 3
assert all(s.seat != 2 for s in scores)
def test_high_power_creature_raises_threat(self):
gs = self._gs_with_creatures()
scores = assess_threats(gs, viewer_seat=1)
seat0_score = next(s for s in scores if s.seat == 0)
seat2_score = next(s for s in scores if s.seat == 2)
assert seat0_score.total > seat2_score.total
def test_scores_normalized_0_to_1(self):
gs = self._gs_with_creatures()
for score in assess_threats(gs, viewer_seat=0):
assert 0.0 <= score.total <= 1.0
def test_sorted_descending(self):
gs = self._gs_with_creatures()
scores = assess_threats(gs, viewer_seat=1)
totals = [s.total for s in scores]
assert totals == sorted(totals, reverse=True)
def test_top_threat_excludes_self(self):
gs = self._gs_with_creatures()
threat = top_threat(gs, viewer_seat=0, exclude_self=True)
assert threat is not None
assert threat.seat != 0
def test_top_threat_includes_self_when_asked(self):
gs = self._gs_with_creatures()
threat = top_threat(gs, viewer_seat=0, exclude_self=False)
# seat 0 has biggest creature — should be top threat
assert threat is not None
assert threat.seat == 0
def test_threat_score_has_raw_dict(self):
gs = _stub_gs()
for score in assess_threats(gs, viewer_seat=0):
assert "total_power" in score.raw
assert "life" in score.raw
# ── TurnManager Construction ────────────────────────────────────────────────
class TestTurnManagerConstruction:
def test_ai_map_built_correctly(self):
tm = _make_tm(num_players=4)
assert len(tm._ai_map) == 4
assert all(seat in tm._ai_map for seat in range(4))
def test_turn_queue_initialized(self):
tm = _make_tm(num_players=4)
assert list(tm._turn_queue) == [0, 1, 2, 3]
def test_human_seats_excluded_from_ai_map(self):
gs = _stub_gs(4)
ai_list = [_offline_ai(i) for i in range(1, 4)] # seats 1-3 only
cfg = TurnManagerConfig(max_turns=1, ai_decision_delay=0.0)
tm = CommanderTurnManager(
game_state=gs,
ai_opponents=ai_list,
human_seats={0},
config=cfg,
)
assert 0 not in tm._ai_map
assert 1 in tm._ai_map
def test_two_player_game(self):
tm = _make_tm(num_players=2)
assert list(tm._turn_queue) == [0, 1]
# ── TurnManager async run_game ──────────────────────────────────────────────
class TestRunGame:
def test_game_completes_within_max_turns(self):
tm = _make_tm(num_players=4, max_turns=2)
winner = asyncio.run(tm.run_game())
# Stub has no elimination logic → game times out, winner by life
assert tm.game_over is True
def test_elimination_removes_from_queue(self):
tm = _make_tm(num_players=4, max_turns=1)
# Manually eliminate seat 2 before running
tm.gs.players[2].eliminated = True
asyncio.run(tm.run_game())
assert 2 not in tm._turn_queue
def test_events_fired(self):
events: list[GameEvent] = []
async def collect(ev: GameEvent):
events.append(ev)
gs = _stub_gs(2)
ai_list = [_offline_ai(0), _offline_ai(1)]
cfg = TurnManagerConfig(max_turns=1, ai_decision_delay=0.0)
tm = CommanderTurnManager(
game_state=gs,
ai_opponents=ai_list,
config=cfg,
on_event=collect,
)
asyncio.run(tm.run_game())
types = {e.event_type for e in events}
assert "phase_change" in types
def test_thinking_callbacks_fired(self):
thinking_log: list[tuple] = []
async def on_think(seat: int, is_thinking: bool):
thinking_log.append((seat, is_thinking))
gs = _stub_gs(2)
ai_list = [_offline_ai(0), _offline_ai(1)]
cfg = TurnManagerConfig(max_turns=1, ai_decision_delay=0.0)
tm = CommanderTurnManager(
game_state=gs, ai_opponents=ai_list, config=cfg, on_thinking=on_think
)
asyncio.run(tm.run_game())
# Each AI turn should emit (seat, True) and (seat, False)
assert len(thinking_log) > 0
starts = [(s, t) for s, t in thinking_log if t]
ends = [(s, t) for s, t in thinking_log if not t]
assert len(starts) == len(ends)
def test_get_stats_structure(self):
tm = _make_tm(num_players=2, max_turns=1)
asyncio.run(tm.run_game())
stats = tm.get_stats()
assert "turn" in stats
assert "total_actions" in stats
assert "game_over" in stats
# ── APNAP Priority ──────────────────────────────────────────────────────────
class TestAPNAP:
def test_phases_constant_has_all_required_phases(self):
for phase in ("untap", "draw", "main1", "main2",
"declare_attackers", "cleanup"):
assert phase in PHASES
def test_action_phases_subset_of_phases(self):
assert ACTION_PHASES.issubset(set(PHASES))
def test_apnap_does_not_deadlock(self):
"""APNAP window should complete even if all AI pass immediately."""
tm = _make_tm(num_players=4, max_turns=1)
async def run():
await tm._apnap_priority_window(
active_seat=0, turn_num=1, phase="main1"
)
asyncio.run(run()) # Should not hang
# ── Threat Score Refresh ────────────────────────────────────────────────────
class TestThreatRefresh:
def test_threat_cache_populated_after_main1(self):
tm = _make_tm(num_players=4, max_turns=1)
asyncio.run(tm.run_game())
# After at least one main1 phase, cache should have entries
assert len(tm._threat_cache) > 0
def test_threat_scores_per_seat_are_lists(self):
tm = _make_tm(num_players=4, max_turns=1)
asyncio.run(tm.run_game())
for seat, scores in tm._threat_cache.items():
assert isinstance(scores, list)
for s in scores:
assert isinstance(s, ThreatScore)
-305
View File
@@ -1,305 +0,0 @@
"""
Phase 4 Unit Tests — Pygame Prototype GUI
==========================================
Run with: pytest tests/test_phase4.py -v
All tests run headless (no display required). Pygame is imported but
we use a null / offscreen display so the test suite passes in CI.
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from typing import Optional
import pytest
# ---------------------------------------------------------------------------
# Headless Pygame setup (must happen before any gui imports)
# ---------------------------------------------------------------------------
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
# ---------------------------------------------------------------------------
# Stub game state (reused from Phase 3 tests, duplicated here for isolation)
# ---------------------------------------------------------------------------
@dataclass
class _StubPlayer:
name: str
life: int = 40
eliminated: bool = False
hand: list = field(default_factory=list)
library: list = field(default_factory=list)
graveyard: list = field(default_factory=list)
battlefield: list = field(default_factory=list)
exile: list = field(default_factory=list)
command_zone: list = field(default_factory=list)
class _StubGameState:
def __init__(self, num_players: int = 4):
self.players = [_StubPlayer(name=f"P{i}") for i in range(num_players)]
self.turn = 3
self.current_phase = "main1"
self.active_player_seat = 0
self.stack = []
def get_legal_moves(self, seat):
return [{"id": 1, "category": "pass_priority", "description": "Pass"}]
def apply_move(self, seat, move_id):
pass
def _gs(n=4):
return _StubGameState(n)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
class TestConstants:
def test_screen_dimensions(self):
from commander_ai_lab.gui.constants import SCREEN_W, SCREEN_H, FPS
assert SCREEN_W == 1280
assert SCREEN_H == 960
assert FPS == 60
def test_four_seat_colours(self):
from commander_ai_lab.gui.constants import SEAT_COLOURS
assert len(SEAT_COLOURS) == 4
for c in SEAT_COLOURS:
assert len(c) == 3
assert all(0 <= v <= 255 for v in c)
def test_all_phases_have_labels(self):
from commander_ai_lab.gui.constants import COMMANDER_PHASES, PHASE_LABELS
for phase in COMMANDER_PHASES:
assert phase in PHASE_LABELS, f"Missing label for phase: {phase}"
def test_card_dimensions_positive(self):
from commander_ai_lab.gui.constants import CARD_W, CARD_H
assert CARD_W > 0
assert CARD_H > 0
assert CARD_H > CARD_W # cards are taller than wide
# ---------------------------------------------------------------------------
# CardRenderer (no display needed for surface ops in dummy mode)
# ---------------------------------------------------------------------------
class TestCardRenderer:
def setup_method(self):
try:
import pygame
pygame.init()
pygame.display.set_mode((1, 1))
except Exception:
pytest.skip("Pygame not available in this environment")
from commander_ai_lab.gui.card_renderer import CardRenderer
self.cr = CardRenderer()
def test_context_menu_land(self):
opts = self.cr.get_context_menu_options({"name": "Forest", "type": "land", "tapped": False})
assert any("mana" in o.lower() for o in opts)
def test_context_menu_creature(self):
opts = self.cr.get_context_menu_options({"name": "Bear", "type": "creature", "is_creature": True})
assert any("attack" in o.lower() for o in opts)
def test_context_menu_always_has_graveyard(self):
opts = self.cr.get_context_menu_options({"name": "Sorcery", "type": "sorcery"})
assert any("graveyard" in o.lower() for o in opts)
def test_card_rect_normal(self):
from commander_ai_lab.gui.constants import CARD_W, CARD_H
x, y, w, h = self.cr.card_rect(10, 20, tapped=False)
assert w == CARD_W
assert h == CARD_H
def test_card_rect_tapped_swaps_dimensions(self):
from commander_ai_lab.gui.constants import CARD_W, CARD_H
x, y, w, h = self.cr.card_rect(10, 20, tapped=True)
assert w == CARD_H
assert h == CARD_W
# ---------------------------------------------------------------------------
# BoardRenderer
# ---------------------------------------------------------------------------
class TestBoardRenderer:
def setup_method(self):
try:
import pygame
pygame.init()
pygame.display.set_mode((1, 1))
except Exception:
pytest.skip("Pygame not available")
from commander_ai_lab.gui.card_renderer import CardRenderer
from commander_ai_lab.gui.board import BoardRenderer
self.br = BoardRenderer(CardRenderer(), human_seat=0)
def test_all_four_seats_have_zones(self):
for seat in range(4):
assert seat in self.br._zones
def test_each_seat_has_five_zones(self):
for seat in range(4):
assert len(self.br._zones[seat]) == 5
def test_zone_names(self):
expected = {"hand", "battlefield", "graveyard", "exile", "command"}
for seat in range(4):
assert set(self.br._zones[seat].keys()) == expected
def test_hit_test_no_cards_returns_none(self):
gs = _gs()
result = self.br.hit_test(0, 0, gs)
# Top-left corner is outside all zone rects for cards
assert result is None or True # may hit a zone but no cards
def test_card_position_first_card(self):
from commander_ai_lab.gui.board import ZoneRect, BoardRenderer
zr = ZoneRect("hand", 0, 0, 600, 140)
x, y = BoardRenderer._card_position(zr, 0, 5)
assert x >= 0
assert y >= 0
def test_card_position_spacing_increases_with_index(self):
from commander_ai_lab.gui.board import ZoneRect, BoardRenderer
zr = ZoneRect("hand", 0, 0, 600, 140)
x0, _ = BoardRenderer._card_position(zr, 0, 5)
x1, _ = BoardRenderer._card_position(zr, 1, 5)
assert x1 > x0
# ---------------------------------------------------------------------------
# HUD
# ---------------------------------------------------------------------------
class TestHUD:
def setup_method(self):
try:
import pygame
pygame.init()
pygame.display.set_mode((1, 1))
except Exception:
pytest.skip("Pygame not available")
from commander_ai_lab.gui.hud import HUD
self.hud = HUD() # no fonts — skips text rendering
def test_render_phase_bar_no_crash(self):
import pygame
surf = pygame.Surface((1280, 960))
self.hud.render_phase_bar(surf, "main1", 0, "P0")
def test_render_life_totals_no_crash(self):
import pygame
surf = pygame.Surface((1280, 960))
players = [_StubPlayer(name=f"P{i}") for i in range(4)]
self.hud.render_life_totals(surf, players)
def test_render_commander_damage_matrix_no_crash(self):
import pygame
surf = pygame.Surface((1280, 960))
players = [_StubPlayer(name=f"P{i}") for i in range(4)]
damage = {(0, 1): 5, (1, 0): 3}
self.hud.render_commander_damage_matrix(surf, players, damage)
def test_render_player_tags_no_crash(self):
import pygame
surf = pygame.Surface((1280, 960))
players = [_StubPlayer(name=f"P{i}") for i in range(4)]
self.hud.render_player_tags(surf, players, active_seat=0, thinking_seats={1})
# ---------------------------------------------------------------------------
# NarrationPanel
# ---------------------------------------------------------------------------
class TestNarrationPanel:
def setup_method(self):
try:
import pygame
pygame.init()
except Exception:
pytest.skip("Pygame not available")
from commander_ai_lab.gui.narration_panel import NarrationPanel
self.panel = NarrationPanel() # no font
def test_add_entry(self):
self.panel.add(seat=0, text="Hello world", turn=1)
assert len(self.panel._entries) >= 1
def test_long_text_wraps(self):
long_text = "This is a very long narration line that should be wrapped by the panel system."
self.panel.add(seat=1, text=long_text)
# Should produce multiple entries
assert len(self.panel._entries) >= 2
def test_thinking_indicator_added(self):
self.panel.add_thinking(seat=2)
assert self.panel._thinking.get(2) is True
def test_thinking_indicator_cleared(self):
self.panel.add_thinking(seat=2)
self.panel.clear_thinking(seat=2)
assert 2 not in self.panel._thinking
def test_clear_wipes_entries(self):
self.panel.add(0, "Test")
self.panel.clear()
assert len(self.panel._entries) == 0
def test_scroll_up_increases_offset(self):
for i in range(20):
self.panel.add(0, f"Line {i}")
self.panel.scroll_up(3)
assert self.panel._scroll_offset == 3
def test_scroll_down_decreases_offset(self):
for i in range(20):
self.panel.add(0, f"Line {i}")
self.panel.scroll_up(5)
self.panel.scroll_down(2)
assert self.panel._scroll_offset == 3
def test_render_no_crash(self):
import pygame
surf = pygame.Surface((1280, 960))
self.panel.add(seat=0, text="Attack!", turn=2)
self.panel.render(surf, turn=2) # should not raise
def test_trim_to_max_lines(self):
from commander_ai_lab.gui.constants import NARRATION_MAX_LINES
for i in range(NARRATION_MAX_LINES * 10):
self.panel.add(0, f"Line {i}")
assert len(self.panel._entries) <= NARRATION_MAX_LINES * 3
# ---------------------------------------------------------------------------
# ZoneRect hit testing
# ---------------------------------------------------------------------------
class TestZoneRect:
def test_contains_inside(self):
from commander_ai_lab.gui.board import ZoneRect
zr = ZoneRect("hand", 10, 20, 100, 50)
assert zr.contains(50, 40)
def test_contains_outside(self):
from commander_ai_lab.gui.board import ZoneRect
zr = ZoneRect("hand", 10, 20, 100, 50)
assert not zr.contains(5, 40)
def test_contains_edge(self):
from commander_ai_lab.gui.board import ZoneRect
zr = ZoneRect("hand", 10, 20, 100, 50)
assert zr.contains(10, 20) # top-left corner — inclusive
assert not zr.contains(110, 70) # bottom-right — exclusive
-250
View File
@@ -1,250 +0,0 @@
"""
Phase 5 Unit Tests — FastAPI Server & WebSocket Channel
=======================================================
Run with: pytest tests/test_phase5.py -v
Requires: pip install fastapi httpx pytest-asyncio
"""
from __future__ import annotations
import json
import pytest
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def app():
"""Create a fresh FastAPI app for each test."""
# Reset singleton so tests don't share session state
from commander_ai_lab.web.session_store import SessionStore
SessionStore._instance = None
from commander_ai_lab.web.app import create_app
return create_app()
@pytest.fixture
def client(app):
from fastapi.testclient import TestClient
return TestClient(app)
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
class TestHealth:
def test_health_ok(self, client):
r = client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_health_has_version(self, client):
r = client.get("/health")
assert "version" in r.json()
# ---------------------------------------------------------------------------
# Game lifecycle
# ---------------------------------------------------------------------------
class TestGameLifecycle:
def _create(self, client, names=None):
names = names or ["A", "B", "C", "D"]
return client.post("/api/v1/games", json={
"player_names": names,
"human_seat": 0,
"ai_personality": ["aggressive", "control", "combo"],
})
def test_create_game_201(self, client):
r = self._create(client)
assert r.status_code == 201
assert "game_id" in r.json()
def test_create_game_wrong_player_count(self, client):
r = client.post("/api/v1/games", json={
"player_names": ["A", "B"],
"human_seat": 0,
"ai_personality": [],
})
assert r.status_code == 400
def test_list_games_empty(self, client):
r = client.get("/api/v1/games")
assert r.status_code == 200
assert r.json()["games"] == []
def test_list_games_after_create(self, client):
self._create(client)
r = client.get("/api/v1/games")
assert len(r.json()["games"]) == 1
def test_get_game_state(self, client):
gid = self._create(client).json()["game_id"]
r = client.get(f"/api/v1/games/{gid}")
assert r.status_code == 200
data = r.json()
assert data["game_id"] == gid
assert len(data["players"]) == 4
def test_get_game_404(self, client):
r = client.get("/api/v1/games/doesnotexist")
assert r.status_code == 404
def test_get_legal_moves(self, client):
gid = self._create(client).json()["game_id"]
r = client.get(f"/api/v1/games/{gid}/moves?seat=0")
assert r.status_code == 200
assert isinstance(r.json()["moves"], list)
assert len(r.json()["moves"]) > 0
def test_submit_move(self, client):
gid = self._create(client).json()["game_id"]
moves = client.get(f"/api/v1/games/{gid}/moves?seat=0").json()["moves"]
move_id = moves[0]["id"]
r = client.post(f"/api/v1/games/{gid}/move", json={"seat": 0, "move_id": move_id})
assert r.status_code == 200
assert r.json()["accepted"] is True
def test_submit_move_404(self, client):
r = client.post("/api/v1/games/bad/move", json={"seat": 0, "move_id": 1})
assert r.status_code == 404
def test_concede(self, client):
gid = self._create(client).json()["game_id"]
r = client.post(f"/api/v1/games/{gid}/concede", json={"seat": 1})
assert r.status_code == 200
assert r.json()["conceded"] is True
def test_delete_game(self, client):
gid = self._create(client).json()["game_id"]
r = client.delete(f"/api/v1/games/{gid}")
assert r.status_code == 204
r2 = client.get(f"/api/v1/games/{gid}")
assert r2.status_code == 404
def test_delete_game_404(self, client):
r = client.delete("/api/v1/games/ghost")
assert r.status_code == 404
# ---------------------------------------------------------------------------
# State snapshot shape
# ---------------------------------------------------------------------------
class TestStateSnapshot:
def test_snapshot_fields(self, client):
gid = client.post("/api/v1/games", json={
"player_names": ["P0","P1","P2","P3"],
"human_seat": 0,
"ai_personality": ["aggressive","control","combo"],
}).json()["game_id"]
snap = client.get(f"/api/v1/games/{gid}").json()
for field in ["game_id","turn","current_phase","active_seat","game_over","winner","players"]:
assert field in snap, f"Missing field: {field}"
def test_snapshot_player_fields(self, client):
gid = client.post("/api/v1/games", json={
"player_names": ["P0","P1","P2","P3"],
"human_seat": 0,
"ai_personality": ["aggressive","control","combo"],
}).json()["game_id"]
snap = client.get(f"/api/v1/games/{gid}").json()
p = snap["players"][0]
for f in ["name","seat","life","eliminated","hand_count","battlefield","graveyard"]:
assert f in p, f"Missing player field: {f}"
def test_starting_life_total(self, client):
gid = client.post("/api/v1/games", json={
"player_names": ["P0","P1","P2","P3"],
"human_seat": 0,
"ai_personality": ["aggressive","control","combo"],
}).json()["game_id"]
snap = client.get(f"/api/v1/games/{gid}").json()
for p in snap["players"]:
assert p["life"] == 40
# ---------------------------------------------------------------------------
# ConnectionManager
# ---------------------------------------------------------------------------
class TestConnectionManager:
def test_connection_count_zero(self):
from commander_ai_lab.web.connection_manager import ConnectionManager
cm = ConnectionManager()
assert cm.connection_count("abc") == 0
def test_seats_connected_empty(self):
from commander_ai_lab.web.connection_manager import ConnectionManager
cm = ConnectionManager()
assert cm.seats_connected("abc") == []
# ---------------------------------------------------------------------------
# SessionStore
# ---------------------------------------------------------------------------
class TestSessionStore:
@pytest.fixture(autouse=True)
def reset_store(self):
from commander_ai_lab.web.session_store import SessionStore
SessionStore._instance = None
yield
SessionStore._instance = None
@pytest.mark.asyncio
async def test_create_and_get(self):
from commander_ai_lab.web.session_store import SessionStore
store = SessionStore()
session = await store.create_session(["A","B","C","D"])
assert store.get_session(session.game_id) is session
@pytest.mark.asyncio
async def test_list_ids(self):
from commander_ai_lab.web.session_store import SessionStore
store = SessionStore()
s1 = await store.create_session(["A","B","C","D"])
s2 = await store.create_session(["W","X","Y","Z"])
ids = store.list_session_ids()
assert s1.game_id in ids
assert s2.game_id in ids
@pytest.mark.asyncio
async def test_remove(self):
from commander_ai_lab.web.session_store import SessionStore
store = SessionStore()
session = await store.create_session(["A","B","C","D"])
ok = store.remove_session(session.game_id)
assert ok is True
assert store.get_session(session.game_id) is None
@pytest.mark.asyncio
async def test_state_snapshot_shape(self):
from commander_ai_lab.web.session_store import SessionStore
store = SessionStore()
session = await store.create_session(["A","B","C","D"])
snap = session.state_snapshot()
assert snap["game_id"] == session.game_id
assert len(snap["players"]) == 4
@pytest.mark.asyncio
async def test_concede_marks_eliminated(self):
from commander_ai_lab.web.session_store import SessionStore
store = SessionStore()
session = await store.create_session(["A","B","C","D"])
await session.concede(seat=2)
assert session._gs.players[2].eliminated is True
assert session._gs.players[2].life == 0
@pytest.mark.asyncio
async def test_legal_moves_returned(self):
from commander_ai_lab.web.session_store import SessionStore
store = SessionStore()
session = await store.create_session(["A","B","C","D"])
moves = session.get_legal_moves(0)
assert isinstance(moves, list)
assert len(moves) > 0
-330
View File
@@ -1,330 +0,0 @@
"""
Phase 6 Unit Tests — Politics Engine & Targeting Memory
=======================================================
Run with: pytest tests/test_phase6.py -v
"""
from __future__ import annotations
import asyncio
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mem(n=4):
from commander_ai_lab.sim.politics.memory import TargetingMemory
return TargetingMemory(num_players=n)
def _engine(personalities=None):
from commander_ai_lab.sim.politics.memory import TargetingMemory
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
from commander_ai_lab.sim.politics.engine import PoliticsEngine
mem = TargetingMemory(num_players=4)
comms = PoliticsCommsChannel()
perso = personalities or {0: "timmy", 1: "spike", 2: "johnny", 3: "aggressive"}
return PoliticsEngine(
num_players=4,
memory=mem,
comms=comms,
personalities=perso,
threat_fn=lambda s: {i: 0.3 for i in range(4) if i != s},
), mem, comms
def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# TargetingMemory
# ---------------------------------------------------------------------------
class TestTargetingMemory:
def test_initial_aggression_zero(self):
mem = _mem()
assert mem.aggression_score(0, 1, current_turn=1) == 0.0
def test_record_increases_aggression(self):
from commander_ai_lab.sim.politics.memory import ActionType
mem = _mem()
mem.record(turn=1, actor=1, target=0, action_type=ActionType.ATTACKED)
assert mem.aggression_score(1, 0, current_turn=1) > 0
def test_different_directions_independent(self):
from commander_ai_lab.sim.politics.memory import ActionType
mem = _mem()
mem.record(1, 0, 1, ActionType.ATTACKED)
assert mem.aggression_score(1, 0, current_turn=1) == 0.0
assert mem.aggression_score(0, 1, current_turn=1) > 0
def test_decay_reduces_score(self):
from commander_ai_lab.sim.politics.memory import ActionType, MEMORY_DECAY_TURNS
mem = _mem()
mem.record(turn=1, actor=1, target=0, action_type=ActionType.ATTACKED)
score_fresh = mem.aggression_score(1, 0, current_turn=2)
score_old = mem.aggression_score(1, 0, current_turn=2 + MEMORY_DECAY_TURNS + 3)
assert score_old < score_fresh
def test_help_reduces_score(self):
from commander_ai_lab.sim.politics.memory import ActionType
mem = _mem()
mem.record(1, 1, 0, ActionType.ATTACKED)
mem.record(2, 1, 0, ActionType.HELPED)
score = mem.aggression_score(1, 0, current_turn=2)
score_attack_only = 1.5 # weight of one ATTACKED event
assert score < score_attack_only
def test_most_aggressive_toward_sorted(self):
from commander_ai_lab.sim.politics.memory import ActionType
mem = _mem()
mem.record(1, 1, 0, ActionType.ATTACKED)
mem.record(1, 1, 0, ActionType.ATTACKED)
mem.record(1, 2, 0, ActionType.TARGETED_SPELL)
ranking = mem.most_aggressive_toward(viewer=0, current_turn=2)
seats = [s for s, _ in ranking]
assert seats[0] == 1 # seat 1 attacked twice
def test_biggest_threat_none_when_empty(self):
mem = _mem()
assert mem.biggest_threat_overall(0, current_turn=1) is None
def test_record_damage_scales_weight(self):
from commander_ai_lab.sim.politics.memory import ActionType
mem = _mem()
mem.record_damage(1, 1, 0, amount=10)
score = mem.aggression_score(1, 0, current_turn=1)
assert score > 0
def test_deal_broken_heavy_weight(self):
from commander_ai_lab.sim.politics.memory import ActionType, ACTION_WEIGHTS
assert ACTION_WEIGHTS[ActionType.BROKE_DEAL] >= 4.0
def test_summary_returns_matrix(self):
from commander_ai_lab.sim.politics.memory import ActionType
mem = _mem(4)
mem.record(1, 0, 1, ActionType.ATTACKED)
summary = mem.summary(current_turn=2)
assert (0, 1) in summary
def test_action_counts(self):
from commander_ai_lab.sim.politics.memory import ActionType
mem = _mem()
mem.record(1, 0, 2, ActionType.ATTACKED)
mem.record(2, 0, 2, ActionType.ATTACKED)
counts = mem.action_counts(0, 2)
assert counts.get(ActionType.ATTACKED) == 2
def test_recent_actions_filter(self):
from commander_ai_lab.sim.politics.memory import ActionType
mem = _mem()
mem.record(1, 0, 1, ActionType.ATTACKED)
mem.record(5, 0, 1, ActionType.TARGETED_SPELL)
recent = mem.recent_actions(last_n_turns=2, current_turn=6)
assert len(recent) == 1
# ---------------------------------------------------------------------------
# Deal data classes
# ---------------------------------------------------------------------------
class TestDeal:
def test_is_active_accepted(self):
from commander_ai_lab.sim.politics.deals import Deal, DealType, DealResponse
d = Deal(proposer=0, target=1, deal_type=DealType.NON_AGGRESSION,
turn_proposed=3, duration_turns=2)
d.response = DealResponse.ACCEPTED
assert d.is_active(current_turn=4) is True
assert d.is_active(current_turn=6) is False
def test_is_pending(self):
from commander_ai_lab.sim.politics.deals import Deal, DealType
d = Deal(proposer=0, target=1, deal_type=DealType.NON_AGGRESSION,
turn_proposed=1, turn_expires=2)
assert d.is_pending(current_turn=2) is True
assert d.is_pending(current_turn=3) is False
def test_expire(self):
from commander_ai_lab.sim.politics.deals import Deal, DealType, DealResponse
d = Deal(proposer=0, target=1, deal_type=DealType.NON_AGGRESSION)
d.expire()
assert d.response == DealResponse.EXPIRED
def test_mark_broken(self):
from commander_ai_lab.sim.politics.deals import Deal, DealType, DealResponse
d = Deal(proposer=0, target=1, deal_type=DealType.NON_AGGRESSION)
d.response = DealResponse.ACCEPTED
d.mark_broken()
assert d.response == DealResponse.BROKEN
def test_unique_ids(self):
from commander_ai_lab.sim.politics.deals import Deal, DealType
d1 = Deal(0, 1, DealType.NON_AGGRESSION)
d2 = Deal(1, 2, DealType.SPARE_THIS_TURN)
assert d1.deal_id != d2.deal_id
# ---------------------------------------------------------------------------
# PoliticsCommsChannel
# ---------------------------------------------------------------------------
class TestPoliticsCommsChannel:
def test_register_and_receive(self):
from commander_ai_lab.sim.politics.comms import (
PoliticsCommsChannel, ThreatBroadcast, BroadcastType,
)
channel = PoliticsCommsChannel()
received = []
async def handler(msg): received.append(msg)
channel.register_handler(seat=1, handler=handler)
msg = ThreatBroadcast(speaker=0, audience=1,
broadcast_type=BroadcastType.THREAT,
text="Watch out", turn=1)
_run(channel.broadcast(msg))
assert len(received) == 1
def test_global_handler_receives_all(self):
from commander_ai_lab.sim.politics.comms import (
PoliticsCommsChannel, ThreatBroadcast, BroadcastType,
)
channel = PoliticsCommsChannel()
received = []
async def handler(msg): received.append(msg)
channel.register_global_handler(handler)
for audience in [1, 2, -1]:
msg = ThreatBroadcast(speaker=0, audience=audience,
broadcast_type=BroadcastType.TAUNT,
text="Hello", turn=1)
_run(channel.broadcast(msg))
assert len(received) == 3
def test_table_broadcast_skips_speaker(self):
from commander_ai_lab.sim.politics.comms import (
PoliticsCommsChannel, ThreatBroadcast, BroadcastType,
)
channel = PoliticsCommsChannel()
received_seats = []
for seat in range(4):
async def make_handler(s=seat):
async def h(msg): received_seats.append(s)
return h
import asyncio
h = asyncio.get_event_loop().run_until_complete(make_handler())
channel.register_handler(seat=seat, handler=h)
msg = ThreatBroadcast(speaker=0, audience=-1,
broadcast_type=BroadcastType.CALL_TO_ARMS,
text="Focus P2!", turn=2)
_run(channel.broadcast(msg))
assert 0 not in received_seats # speaker does not receive own broadcast
def test_recent_broadcasts(self):
from commander_ai_lab.sim.politics.comms import (
PoliticsCommsChannel, ThreatBroadcast, BroadcastType,
)
channel = PoliticsCommsChannel()
for i in range(15):
msg = ThreatBroadcast(0, -1, BroadcastType.TAUNT, f"msg{i}", turn=i)
_run(channel.broadcast(msg))
recent = channel.recent_broadcasts(last_n=5)
assert len(recent) == 5
# ---------------------------------------------------------------------------
# PoliticsEngine
# ---------------------------------------------------------------------------
class TestPoliticsEngine:
def test_propose_deal_creates_entry(self):
from commander_ai_lab.sim.politics.deals import DealType
engine, mem, comms = _engine()
deal = _run(engine.propose_deal(0, 1, DealType.NON_AGGRESSION, current_turn=2))
assert deal.deal_id in engine._deals
def test_deal_gets_response(self):
from commander_ai_lab.sim.politics.deals import DealType, DealResponse
engine, mem, comms = _engine()
deal = _run(engine.propose_deal(0, 1, DealType.NON_AGGRESSION, current_turn=2))
assert deal.response in (DealResponse.ACCEPTED, DealResponse.REJECTED)
def test_active_deals_returned(self):
from commander_ai_lab.sim.politics.deals import DealType, DealResponse
engine, mem, comms = _engine()
deal = _run(engine.propose_deal(0, 1, DealType.NON_AGGRESSION, current_turn=2))
deal.response = DealResponse.ACCEPTED
active = engine.get_active_deals(seat=0, current_turn=2)
assert any(d.deal_id == deal.deal_id for d in active)
def test_deal_violation_marks_broken(self):
from commander_ai_lab.sim.politics.deals import DealType, DealResponse
from commander_ai_lab.sim.politics.memory import ActionType
engine, mem, comms = _engine()
deal = _run(engine.propose_deal(0, 1, DealType.NON_AGGRESSION, current_turn=2))
deal.response = DealResponse.ACCEPTED
broken = engine.check_deal_violation(
actor=1, action_target=0,
action_type=ActionType.ATTACKED,
current_turn=3,
)
assert len(broken) == 1
assert broken[0].response == DealResponse.BROKEN
def test_spite_set_after_violation(self):
from commander_ai_lab.sim.politics.deals import DealType, DealResponse
from commander_ai_lab.sim.politics.memory import ActionType
engine, mem, comms = _engine()
deal = _run(engine.propose_deal(0, 1, DealType.NON_AGGRESSION, current_turn=2))
deal.response = DealResponse.ACCEPTED
engine.check_deal_violation(1, 0, ActionType.ATTACKED, current_turn=3)
spite = engine.get_spite_bias(viewer=0, target=1, current_turn=3)
assert spite > 0
def test_spite_decays_after_turns(self):
from commander_ai_lab.sim.politics.deals import DealType, DealResponse
from commander_ai_lab.sim.politics.memory import ActionType
from commander_ai_lab.sim.politics.engine import SPITE_DECAY_TURNS
engine, mem, comms = _engine()
deal = _run(engine.propose_deal(0, 1, DealType.NON_AGGRESSION, current_turn=2))
deal.response = DealResponse.ACCEPTED
engine.check_deal_violation(1, 0, ActionType.ATTACKED, current_turn=3)
engine._decay_spite(current_turn=3 + SPITE_DECAY_TURNS + 1)
spite = engine.get_spite_bias(viewer=0, target=1,
current_turn=3 + SPITE_DECAY_TURNS + 1)
assert spite == 0.0
def test_adjusted_threat_includes_spite(self):
from commander_ai_lab.sim.politics.deals import DealType, DealResponse
from commander_ai_lab.sim.politics.memory import ActionType
engine, mem, comms = _engine()
base_score = engine.adjusted_threat_score(viewer=0, target=1, current_turn=2)
# Add spite
engine._add_spite(wronged_seat=0, target_seat=1, current_turn=2, intensity=0.6)
spite_score = engine.adjusted_threat_score(viewer=0, target=1, current_turn=2)
assert spite_score > base_score
def test_no_deal_within_cooldown(self):
from commander_ai_lab.sim.politics.engine import DEAL_PROPOSAL_COOLDOWN
engine, mem, comms = _engine()
engine._last_proposal_turn[0] = 5
count_before = len(engine._deals)
class _FakeGS:
players = [type('P', (), {'eliminated': False, 'life': 40})() for _ in range(4)]
_run(engine._maybe_propose_deal(0, current_turn=5 + DEAL_PROPOSAL_COOLDOWN - 1,
game_state=_FakeGS()))
assert len(engine._deals) == count_before
def test_comms_log_populated_after_proposal(self):
from commander_ai_lab.sim.politics.deals import DealType
engine, mem, comms = _engine()
_run(engine.propose_deal(0, 1, DealType.SPARE_THIS_TURN, current_turn=3))
assert len(comms._log) >= 2 # proposal + response
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more