Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f47b5182d2 | ||
|
|
5f606fee22 | ||
|
|
f5dae81193 | ||
|
|
36b3c8c55b | ||
|
|
a098524b98 | ||
|
|
acc9d7cd47 | ||
|
|
588314b282 | ||
|
|
7132b6f205 | ||
|
|
1704d7ca9c | ||
|
|
f639703fb4 | ||
|
|
cbedfd1ed2 | ||
|
|
be55a02860 |
No files matched your search
-21
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
@@ -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,
|
||||
|
||||
@@ -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 []
|
||||
@@ -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 []
|
||||
@@ -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 []
|
||||
@@ -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 []
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
-8872
File diff suppressed because it is too large.
Load diff
-4487
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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__":
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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',
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+2
-6
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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]]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 ╔══════════════════════════════════════════════════╗
|
||||
|
||||
Whitespace-only changes.
@@ -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()
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in new issue
Block a user