Clean up backend folder - remove obsolete scripts and test files

Removed obsolete scripts that were not imported or used:
- card_interaction_rule_engine.py
- card_profile_extractor.py
- create_card_interaction_graph.py
- interaction_determinator.py
- interaction_pipeline.py
- interaction_recommender.py
- interaction_schema.py
- recommendation_engine.py
- migrate_complete.py
- migrate_schema.py
- test_interaction_determinator.py
- check_mtgjson_full.py
- check_mtgjson_status.py
- verify_integration.py
- verify_mtgjson_data.py
- sanity_check_mtgjson.py
- investigate_sets.py
- inspect_db.py
- code_review.md
- monitor/mtg_monitor.py

Removed test artifacts:
- test.db
- test_download.py
- test_system.py
- setup_db.py
- BACKEND_TESTING_SUMMARY.md
- CHAT_PROMPT_TEST.md
- CONTINUATION_PROMPT.md
- PORTED_STATE.md
- SPEC_synergy-mapping-engine.md
- STATE.md
- SUPPORTED_FILE_TYPES.md

Removed sensitive/environment files:
- .env.local
- state.json (backend)

Cleaned up:
- __pycache__ directories
- venv directory

Backend scripts/ directory now contains only essential data loading and maintenance scripts.
This commit is contained in:
2026-07-22 03:00:04 +00:00
parent b4a0b1f8be
commit a01e33eb5e
35 changed files with 1093 additions and 9215 deletions
+488
View File
@@ -0,0 +1,488 @@
# Cockatrice Architecture Analysis
## Target: /home/wall-o/projects/mtgonline/C++
## Date: 2026-07-20
---
## 1. Project Overview
**Cockatrice** (v3.1.0 "Graduation Day") is a mature open-source MTG online client/server
application written in C++20 using Qt 5/6. It has been maintained for many years and
represents a battle-tested implementation of an online card game.
### Key Architectural Decisions:
- **Server-Client separation**: The server (`servatrice`) is authoritative; the client
(`cockatrice`) is a thin visualizer that sends commands through the server.
- **Protocol Buffers**: All network communication uses `.proto` files — no custom
serialization. This is the canonical contract between components.
- **Event-driven game loop**: Qt signals/slots drive game state transitions.
- **Plugin-like library structure**: Core logic lives in `libcockatrice_*` shared libraries.
---
## 2. Top-Level Directory Structure
```
Cockatrice/
├── CMakeLists.txt # Root build system (C++20, Qt5/6, Protobuf)
├── servatrice/ # SERVER application
│ ├── src/
│ │ ├── servatrice.cpp/h # Main server class
│ │ ├── serversocketinterface.cpp/h # Per-client socket handler (~106KB — single massive file)
│ │ ├── servatrice_database_interface.cpp/h # Database operations
│ │ ├── isl_interface.cpp/h # Inter-server-link (cluster) support
│ │ ├── smtp/ # Email support
│ │ └── main.cpp/h
│ ├── migrations/ # DB schema migrations
│ └── resources/ # Config, SQL init
├── cockatrice/ # CLIENT application
│ ├── src/
│ │ ├── client/ # Client network layer
│ │ │ ├── network/
│ │ │ │ ├── connection_controller/ # Connection lifecycle
│ │ │ │ ├── interfaces/ # Client-server interface abstractions
│ │ │ │ ├── parsers/ # Protocol message parsers
│ │ │ │ └── update/ # Auto-update checks
│ │ │ ├── sound_engine.cpp/h # Audio feedback
│ │ │ └── settings/
│ │ ├── game/ # GAME ENGINE
│ │ │ ├── abstract_game.h/cpp # Game abstraction
│ │ │ ├── game.cpp/h # Concrete game instance
│ │ │ ├── game_event_handler.cpp/h # Central event dispatch
│ │ │ ├── game_meta_info.h/cpp # Game metadata (wraps protobuf)
│ │ │ ├── game_state.h/cpp # Board state tracking
│ │ │ ├── phase.h/cpp # Turn phase definitions
│ │ │ ├── player/ # Player logic
│ │ │ │ ├── player_actions.cpp/h # Player commands
│ │ │ │ ├── player_event_handler.cpp/h
│ │ │ │ ├── player_logic.cpp/h # Player AI/logic
│ │ │ │ ├── player_manager.cpp/h # Multiplayer coordination
│ │ │ │ └── event_processing_options.h
│ │ │ ├── board/ # Board visualization state
│ │ │ │ ├── card_list.cpp/h
│ │ │ │ ├── card_state.cpp/h
│ │ │ │ └── counter_state.cpp/h
│ │ │ ├── zones/ # Zone logic
│ │ │ │ ├── card_zone_algorithms.h
│ │ │ │ ├── card_zone_logic.cpp/h
│ │ │ │ ├── hand_zone_logic.cpp/h
│ │ │ │ ├── pile_zone_logic.cpp/h
│ │ │ │ ├── stack_zone_logic.cpp/h
│ │ │ │ ├── table_zone_logic.cpp/h
│ │ │ │ └── view_zone_logic.cpp/h
│ │ │ ├── replay.cpp/h # Game replay system
│ │ │ └── arrow_registry.cpp/h # Card targeting arrows
│ │ ├── game_graphics/ # Visual rendering layer
│ │ │ ├── game_scene.cpp/h # QGraphicsScene for the board
│ │ │ ├── game_view.cpp/h # View controller
│ │ │ ├── board/
│ │ │ ├── deckview/
│ │ │ ├── dialogs/
│ │ │ ├── phases_toolbar.cpp/h # Phase navigation UI
│ │ │ ├── player/
│ │ │ ├── tally/ # Life total displays
│ │ │ ├── z_value_layer_manager.h
│ │ │ └── z_values.h
│ │ ├── database/
│ │ │ └── interface/
│ │ ├── filters/ # Deck/card filter system
│ │ │ ├── deck_filter_string.cpp/h
│ │ │ ├── filter_builder.cpp/h
│ │ │ └── filter_tree_model.cpp/h
│ │ ├── interface/ # UI/UX layer
│ │ │ ├── window_main.cpp/h # Main application window
│ │ │ ├── layouts/
│ │ │ ├── widgets/
│ │ │ ├── theme_manager.cpp/h # Theming system
│ │ │ ├── card_picture_loader/
│ │ │ ├── deck_loader/
│ │ │ ├── pixel_map_generator.cpp/h
│ │ │ ├── logger.cpp/h
│ │ │ └── palette_editor/
│ │ └── main.cpp/h # Client entry point
├── oracle/ # CARD DATABASE TOOL
│ └── (downloads card data from MTGJSON)
├── libcockatrice_card/ # Card data model library
│ └── libcockatrice/card/
│ ├── card_info.cpp/h # Card information model
│ ├── database/ # Card database access
│ ├── format/ # Deck format support
│ ├── import/ # Deck importers
│ ├── printing/ # Printing history
│ ├── relation/ # Card relationships
│ └── set/ # Set data
├── libcockatrice_deck_list/ # Deck list management library
│ └── libcockatrice/deck_list/
│ ├── deck_list.cpp/h # Core deck list class
│ ├── deck_list_node_tree.cpp/h # Tree structure for decks
│ ├── sideboard_plan.cpp/h # Sideboarding
│ └── tree/
├── libcockatrice_network/ # Network protocol library
│ └── libcockatrice/network/
│ └── network/ # TCP client/server
├── libcockatrice_protocol/ # Protocol definition library
│ └── libcockatrice/protocol/ # .proto file translations to C++
├── libcockatrice_rng/ # RNG library (SFMT)
├── libcockatrice_settings/ # Settings/preferences
├── libcockatrice_utility/ # Utility functions
├── docker-compose.yml # Dev environment (MySQL + Servatrice)
└── doc/ # Documentation (Doxygen)
```
---
## 3. Gameplay Engine Architecture
### 3.1 Core Game Loop — Event-Driven Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ GAME ENGINE │
│ │
│ ┌──────────────┐ ┌──────────────────┐ ┌───────────────────┐ │
│ │ Client │ │ Game │ │ Event Handler │ │
│ │ (User Input)│──▶│ (AbstractGame) │◀──│ (Central Dispatch)│ │
│ └──────────────┘ └──────────────────┘ └───────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ GameState │ │ PlayerManager │ │
│ │ (Board State)│ │ (Multiplayer │ │
│ │ │ │ Coordination) │ │
│ └─────────────┘ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ CardZone │ │ PlayerLogic │ │
│ │ (Hand/ │ │ (Actions, Rules │ │
│ │ Stack/ │ │ Processing) │ │
│ │ Table/ │ └──────────────────┘ │
│ │ Graveyard) │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
### 3.2 Game Class Hierarchy
| Class | Role | Key Data |
|-------|------|----------|
| `AbstractGame` | Base game abstraction | `gameMetaInfo`, `gameState`, `gameEventHandler`, `playerManager` |
| `Game` | Concrete online/offline game | Extends AbstractGame with client list |
| `GameMetaInfo` | Wraps protobuf `ServerInfo_Game` | gameId, maxPlayers, description, started, spectators settings |
| `GameState` | Board state tracking | currentPhase, activePlayer, hostId, gameTimer, clients |
| `GameEventHandler` | **Central event dispatch** (~21KB handler) | Processes all game events, prepares commands |
| `Phase` | Turn phase definitions | 11 phases with sub-phases, colors, sounds |
### 3.3 Phase System (MTG Turn Structure)
```
Phases::phases[] array contains 11 phases:
1. Untap
2. Upkeep
3. Draw
4. Main 1
5. Combat (with sub-phases)
- Beginning of Combat
- Declare Attackers
- Declare Blockers
- Combat Damage
- End of Combat
6. Main 2
7. End of Turn
8. Cleanup
(plus unknownPhase as sentinel)
```
Sub-phases are tracked via `Phases::subPhasesEnd` — the combat phase has 5 sub-phases.
### 3.4 Player System
| Class | Role | File Size |
|-------|------|-----------|
| `PlayerManager` | Coordinates all players in a game | ~2.5KB header |
| `PlayerLogic` | Per-player game logic (actions, rules) | ~10.7KB |
| `PlayerActions` | Concrete player commands (play, tap, draw, etc.) | **~64KB** — single massive file |
| `PlayerEventHandler` | Per-player event processing | ~24KB |
| `EventProcessingOptions` | Flags controlling event processing | ~575B |
### 3.5 Zone System
| Zone | File | Description |
|------|------|-------------|
| `CardZone` | `card_zone_logic.cpp/h` | Base class for all zones |
| `HandZone` | `hand_zone_logic.cpp/h` | Player's hand (secret zone) |
| `StackZone` | `stack_zone_logic.cpp/h` | The stack ( spells, abilities) |
| `TableZone` | `table_zone_logic.cpp/h` | Battlefield (permanent zone) |
| `PileZone` | `pile_zone_logic.cpp/h` | Graveyard, exile, library |
| `ViewZone` | `view_zone_logic.cpp/h` | Shared view zones (for effects) |
| `CardZoneAlgorithms` | `card_zone_algorithms.h` | Search, shuffle, sort algorithms |
Each zone type implements its own `CardZoneLogic` subclass.
### 3.6 Command/Event Architecture
```
User Action (GUI)
GameCommand (protobuf message)
GameEventHandler.processGameEventContainer()
PlayerLogic.handleCommand()
CardZoneLogic (state change)
GameEvent (sent to all clients)
Client receives & displays
```
### 3.7 Replay System
`replay.cpp/h` — Games are recorded as a stream of protobuf events and can be
replayed identically. This is a direct serialization of `GameReplay` protobuf messages.
### 3.8 Card State on Board
| Class | Role |
|-------|------|
| `CardState` | Card face-up/face-down, tapped, counters |
| `CardList` | Ordered/unordered collection of cards in a zone |
| `CounterState` | Life total, poison counters, etc. |
| `ArrowData` | Targeting arrow visual |
| `ArrowRegistry` | Manages targeting arrows |
---
## 4. Network Protocol Architecture
### 4.1 Communication Stack
```
┌─────────────────────────────────────────────────────────┐
│ Application Layer │
│ - GameCommands (play, draw, tap, attack, etc.) │
│ - GameEvents (state changes broadcast to all clients) │
│ - Chat messages, user management │
│ - Deck management │
│ - Room management │
│ - Admin commands │
├─────────────────────────────────────────────────────────┤
│ Protocol Layer │
│ - Protocol Buffers (.proto files) │
│ - Message types: ServerInfo_Game, ServerInfo_Player, │
│ Event_Join, Command_PlayCard, etc. │
├─────────────────────────────────────────────────────────┤
│ Network Layer │
│ - TCP connections │
│ - Connection pooling │
│ - ISL (Inter-Server Link) for clustering │
└─────────────────────────────────────────────────────────┘
```
### 4.2 Key Protocol Messages (from .pb files)
| Category | Example Messages |
|----------|-----------------|
| **Game State** | `ServerInfo_Game`, `ServerInfo_Player`, `Event_GameStateChanged` |
| **Player Actions** | `Command_PlayCard`, `Command_Attack`, `Command_Damage`, `Command_DrawCard` |
| **Events** | `Event_Join`, `Event_Leave`, `Event_SetActivePlayer`, `Event_SetActivePhase` |
| **User Management** | `ServerInfo_User`, `Command_Acknowledge`, `Command_SpectateGame` |
| **Chat** | `Event_GameSay`, `ServerInfo_Chat` |
| **Decks** | Deck list serialization, sideboard plans |
### 4.3 Authority Model
- **Server is authoritative**: All game state changes go through the server.
- **Client sends commands, server validates**: The client cannot manipulate the game state directly.
- **Events are broadcast**: Server sends `GameEvent` containers to all connected clients.
- **Replays are deterministic**: Same command sequence produces identical game states.
---
## 5. Server Architecture (Servatrice)
### 5.1 Server Class Hierarchy
| Class | Role |
|-------|------|
| `Servatrice` | Main server class (~42KB) — manages connections, rooms, games |
| `ServatriceDatabaseInterface` | Database operations (~58KB) — user accounts, decks, logs |
| `ServerSocketInterface` | Per-client handler (~106KB) — handles all client messages |
| `IslInterface` | Inter-server-link for clustering |
| `ServerLogger` | Centralized logging |
### 5.2 Server Features
- **Room management**: Games are organized in rooms; rooms have game types.
- **User accounts**: MySQL-backed with authentication, password reset, email.
- **Deck storage**: Decks saved to database with versioning.
- **Game logging**: Full game event logs for disputes.
- **Spectator system**: Spectators can watch (with optional omniscient mode).
- **Admin commands**: Kicking, banning, game management.
- **Clustering**: ISL protocol for running multiple servers in a cluster.
- **Email**: SMTP support for registration/password reset.
### 5.3 Database Schema (MySQL)
The `servatrice.sql` file defines the schema including:
- User accounts, groups, bans
- Deck lists with versioning
- Game logs
- Room/game state tables
- Admin logs
---
## 6. Card Database (Oracle)
The `oracle` module downloads and maintains the card database:
- Sources: MTGJSON data
- Local storage: SQLite or file-based
- Features: Card searching, printing history, set filtering
- Updates: Periodic refresh capability
---
## 7. Deck Management
| Component | Role |
|-----------|------|
| `DeckList` | Core deck data structure (cards, sideboard, categories) |
| `DeckListNodeTree` | Tree structure for organizing decks |
| `SideboardPlan` | Pre/post-sideboard plan management |
| `DeckListHistoryManager` | Deck version history |
| `DeckFilterString` | Card name filtering |
Deck formats supported: Commander, Standard, Modern, Legacy, Vintage, etc.
---
## 8. UI/Rendering Architecture
### 8.1 Qt Graphics Framework
```
┌─────────────────────────────────────────────────────────┐
│ Window Main (window_main.cpp) — ~44KB │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Game │ │ Chat/Log │ │ Deck/Filter │ │
│ │ Scene │ │ Panel │ │ Panel │ │
│ │ (QScene) │ │ │ │ │ │
│ └──────────┘ └──────────────┘ └──────────────────┘ │
│ │ │
│ ▼ │
│ GameScene (game_scene.cpp) — ~23KB │
│ GameView (game_view.cpp) — ~10KB │
│ PhasesToolbar — Phase navigation │
│ ZValueLayerManager — 2D depth ordering │
└─────────────────────────────────────────────────────────┘
```
### 8.2 Theming System
- SVG-based card back designs
- Configurable themes (backgrounds, colors, layouts)
- Card picture loading from external URLs
- Custom counters and tally displays
---
## 9. Technology Stack Summary
| Layer | Technology |
|-------|-----------|
| Language | C++20 |
| GUI Framework | Qt 5 or Qt 6 (QtWidgets) |
| Network Protocol | Protocol Buffers (3.21+) |
| Build System | CMake 3.10+ |
| Server Database | MySQL |
| Card Database | SQLite / file-based (Oracle) |
| Packaging | CPack (DEB, RPM, NSIS, DMG) |
| Networking | Qt TCP |
| Serialization | protobuf |
| RNG | SFMT (Sobol/Smirnov) |
| Translations | Qt .ts files |
| Documentation | Doxygen |
---
## 10. Key Architectural Patterns
| Pattern | Where Used |
|---------|-----------|
| **MVC (via Qt)** | `game_state` (model), `game_scene` (view), `GameEventHandler` (controller) |
| **Event Bus** | Qt signals/slots for all state transitions |
| **Command Pattern** | `Command_PlayCard`, `Command_Attack`, etc. — all game actions are commands |
| **Observer** | `GameEventHandler` emits signals to update UI on every state change |
| **Strategy** | `CardZoneLogic` subclasses (Hand, Stack, Table, Pile, View) |
| **Facade** | `AbstractGame` wraps all game subsystems behind a single interface |
| **Adapter** | `GameMetaInfo` wraps protobuf messages with Qt-friendly getters |
| **Singleton** | Settings cache, database interface |
| **Memento** | `DeckListMemento` for undo/redo in deck editing |
| **Repository** | `ServatriceDatabaseInterface` abstracts MySQL access |
| **Plugin (debatable)** | ISL interface for adding server nodes |
---
## 11. Strengths & Weaknesses (for modern web adaptation)
### Strengths
- **Clear authority model**: Server is always right — ideal for web multiplayer.
- **Deterministic replay**: Game events are serialized, enabling perfect replays.
- **Zone abstraction**: Each zone type is independently implementable.
- **Phase system**: Well-defined MTG turn structure.
- **Command/Event separation**: Clean separation between what a player *wants* to do and what *happens*.
### Weaknesses (for web adaptation)
- **Desktop-first**: Qt Widgets, not designed for browser deployment.
- **Massive files**: `serversocketinterface.cpp` is 106KB — monolithic.
- **No web protocol**: Only TCP, no WebSocket, no REST.
- **No mobile**: No responsive design, no mobile clients.
- **Complex build**: Qt + CMake + protobuf + MySQL — heavy dev environment.
- **No real-time scaling**: Single-server architecture (ISL is clustering, not load balancing).
- **No card images built-in**: External URL loading only.
- **No API**: No REST/GraphQL for external integrations.
---
## 12. Relevance to Modern Web MTG App
### What to Reuse (Architecture Patterns)
1. **Event-driven game state**: Server authoritative, client event-driven UI.
2. **Zone abstraction**: Hand/Stack/Table/Graveyard/Exile/Library as separate zone types.
3. **Command pattern**: Player actions as discrete commands, validated server-side.
4. **Phase system**: 11-phase MTG turn structure with sub-phases.
5. **Replay system**: Serialize game events for replay capability.
6. **Card database**: Structure for card info, sets, printing history.
7. **Deck management**: Tree structure, sideboard plans, format support.
### What to Modernize
1. **Protocol**: Replace protobuf over TCP with **WebSocket** (or gRPC-Web) for browser.
2. **API**: Add **REST/GraphQL** layer for external integrations.
3. **Frontend**: Replace Qt Widgets with **React/Next.js** or **Vue 3**.
4. **State management**: Replace Qt signals/slots with **state machines** (XState) or **Zustand**.
5. **Backend**: Replace C++/MySQL with **TypeScript/Node.js** or **Python/FastAPI** + **PostgreSQL**.
6. **Card images**: Bundle card images with the application or use CDN.
7. **Real-time**: Use **WebSocket** for live game state sync.
8. **Mobile**: Responsive design from the start.
9. **Testing**: Unit test the game engine in isolation (no GUI dependency).
### Suggested Tech Stack for Modern Web MTG
| Component | Recommendation |
|-----------|---------------|
| Frontend | Next.js 14+ (React, TypeScript) |
| Game State | XState (state machines) or Zustand |
| UI Rendering | React Three Fiber (3D) or PixiJS (2D) |
| Backend | FastAPI (Python) or Hono (TypeScript) |
| Database | PostgreSQL (user data) + Redis (game state cache) |
| Real-time | WebSocket (via FastAPI or Socket.IO) |
| Card Data | MTGJSON v5 (already available in project) |
| Auth | JWT + refresh tokens |
| Deployment | Docker Compose (already used in project) |
+110 -31
View File
@@ -255,44 +255,123 @@ docker exec <mtgdata_container_id> psql -U mtgonline_user mtgdata -c "SELECT * F
- Check CORS_ORIGINS setting in app/core/settings.py - Check CORS_ORIGINS setting in app/core/settings.py
- Ensure frontend URL matches allowed origins - Ensure frontend URL matches allowed origins
## Next Phase: Backend Expansion for Frontend Support ## Next Phase: V1 Backend — Deck Building & Card Management (NEW SCOPE)
**Primary Focus**: Expand the PostgreSQL database schema and API endpoints to support frontend deckbuilding and gameplay features on a per-user basis. The v1 app function focuses on three core capabilities:
1. **Per-user deck building** with card search, deck precedents, and card suggestions
2. **Card list import** from spreadsheet/text files with fuzzy matching
3. **Multiplayer gameplay** (handled by a separate backend)
### Database Schema Expansion ### 1. Per-User Deck Building
- Per-user deck storage (decks, folders, custom card sets)
- Game state persistence (saved games, match history, game logs)
- User card collection tracking (owned cards, favorites)
- Game room state management (active games, waiting lists)
- Tournament and custom rule support
### API Endpoints to Implement #### Database Schema
- Deck CRUD with user ownership and sharing - **`user_decks` table** (per-user storage in primary `mtgonline` database):
- Game room creation, joining, and state management - `deck_id` (PK, auto-increment)
- Real-time WebSocket endpoints for multiplayer gameplay - `user_id` (FK → users)
- Card collection APIs (search, filter, organize) - `name` (text)
- Game history and replay APIs - `status` (ENUM: `DRAFT`, `FINAL`)
- Admin tools for game monitoring and moderation - `DRAFT` = works in progress, can be edited freely
- `FINAL` = user considers it complete, no further changes expected
- `cards` (JSONB or separate junction table with `card_id`, `quantity`)
- `created_at`, `updated_at` (timestamps)
- `folder_id` (FK → user folders, optional)
### Frontend Features to Support (from ROADMAP.md Phase 2) #### API Endpoints to Implement
- **Deck Builder**: Card search with filters (name, color, type, set), drag-and-drop editor, import/export formats - `POST /decks/` — Create new draft deck (auto status: DRAFT)
- **Game Interface**: Game board visualization, player zones (hand, library, graveyard, exile, command), real-time updates - `GET /decks/` — List user's decks, filtered by status
- **Chat System**: Room chat, game chat, player list, moderator tools - `GET /decks/{deck_id}` — Get full deck details
- **Admin Dashboard**: User management, ban/unban controls, game logs, system statistics - `PATCH /decks/{deck_id}` — Update deck (name, status, card list)
- `POST /decks/{deck_id}/finalize` — Transition DRAFT → FINAL
- `DELETE /decks/{deck_id}` — Delete deck (only if FINAL, or admin override)
- `GET /decks/{deck_id}/cards` — Get cards in deck with quantity counts
### Integration Requirements (from ROADMAP.md Phase 3) #### Deck Building Features
- WebSocket client with reconnection logic - **Card Search**: Search the MTG card database by name, type, set, color, etc. Returns matching cards with full details.
- Card database caching and search functionality - **Deck Precedents**: Preset/starting deck templates that users can use as a basis. Could be built-in (e.g., "Starter Deck") or user-saved as FINAL decks to be reused.
- Game logic implementation (turn-based state, mana tracking, stack resolution) - **Card Suggestion**: Given a card already in the deck, suggest similar cards (same type, same color, same set, same mana cost, or cards often paired with the input card in existing decks).
- Performance optimization (virtual scrolling, memoization, code splitting)
### Deployment & Production (from ROADMAP.md Phase 4) ### 2. Card Import from Files
- Docker Compose for development and production
- CI/CD pipeline with GitHub Actions
- Security hardening (rate limiting, input validation, HTTPS)
- Monitoring and alerting (structured logging, error tracking)
See **ROADMAP.md** for complete feature specifications and timeline. #### Supported Formats
- XLSX (Excel)
- CSV
- JSON
- ODS (OpenDocument Spreadsheet)
#### Import Flow
1. User uploads a file (XLSX, CSV, JSON, or ODS)
2. Backend parses the file — each row is treated as one card entry
3. Duplicates within the file are allowed (each row → one card instance)
4. For each card name in the file, backend performs **fuzzy matching** against the `Cards` PSQL table
5. Matched cards are stored in a **user-owned card table** with metadata:
- `user_id` (FK → users)
- `card_id` (FK → mtg_cards from mtgdata, matched via fuzzy search)
- `raw_name` (original name from file, for traceability)
- `confidence` (match score from fuzzy search)
- `imported_at` (timestamp)
#### Fuzzy Search Requirements
- Must handle **spelling errors** (e.g., "Wondrrland" → "Wonderland")
- Must handle **American vs British English** differences (e.g., "color" vs "colour", "armor" vs "armour")
- Use a fuzzy string matching library (e.g., `python-Levenshtein`, `thefuzz`/`fuzzymatch`)
- Confidence threshold to auto-accept vs flag for manual review
- Bulk matching: process all cards in the file in a single batch operation
#### API Endpoints to Implement
- `POST /cards/import` — Upload file for import
- `GET /cards/import/{import_id}/status` — Check import progress/status
- `GET /cards/import/{import_id}/results` — Get match results with confidence scores
- `POST /cards/import/{import_id}/confirm` — Confirm import (save to user card table)
- `GET /user/cards` — List user's imported/owned cards
- `DELETE /user/cards/{card_import_id}` — Remove from user card table
### 3. Multiplayer Play Feature (Separate Backend)
#### Architecture Decision
The multiplayer gameplay feature will live in a **separate backend codebase** to ensure smooth, independent development. This backend will communicate with the card backend via:
- **API Calls**: For authentication, user data, deck retrieval, card lookups
- **Direct PSQL Queries**: For card data and user deck data
#### Integration Points
- **Card Backend API** (`http://backend:8000`):
- `GET /api/cards/{card_id}` — Get card details for in-game display
- `GET /api/cards/search?q=...` — Search cards during gameplay
- `GET /api/sets/` — List available sets for game formatting
- **PSQL Direct Access** (via shared connection string):
- `mtgdata` database — Read card data (cards, sets, etc.)
- `mtgonline` database — Read user decks (for deck validation, game setup)
#### API Endpoints for Play Backend
- `POST /play/decks/{deck_id}/validate` — Validate deck against card database
- `GET /play/users/{user_id}/decks` — Get user's FINAL decks for selection
- `GET /play/cards/{card_id}` — Get card details for game board display
**Note**: The play backend is out of scope for this document. See separate codebase/repository when ready.
### Summary of Work Required in This Backend
| Feature | Database | API | Notes |
|---------|----------|-----|-------|
| User deck CRUD | `mtgonline` (new tables) | Full CRUD + finalize | DRAFT/FINAL status |
| Card search | `mtgdata` (existing) | Search endpoint | Leverages existing card DB |
| Card suggestions | `mtgonline` + `mtgdata` | Suggestion endpoint | Based on similar cards |
| File import (XLSX/CSV/JSON/ODS) | `mtgonline` (new user cards table) | Upload + confirm | Fuzzy match required |
| Fuzzy matching service | N/A | Internal service | Handles spelling + EN variants |
| Play backend integration | Read-only access | API consumer | Separate codebase |
### Files to Create/Modify
- New models: `models/user_deck.py`, `models/user_card.py`
- New migrations: Alembic migrations for new tables
- New router: `routers/decks.py`, `routers/card_import.py`
- New service: `services/fuzzy_card_matcher.py`
- New service: `services/deck_suggestion.py`
- New schema: `schemas/deck.py`, `schemas/card_import.py`
- Update `core/database.py` if new engine needed
- Update `requirements.txt` with fuzzy matching libraries
See **ROADMAP.md Phase 2** for detailed task breakdown.
## State File ## State File
-83
View File
@@ -1,83 +0,0 @@
# MTG Online Backend - Handoff Prompt
## Context
You are taking over the MTG Online Backend project. The project is a Python FastAPI application that integrates with MTGJSON API to provide Magic: The Gathering card data. The system downloads, processes, and stores MTGJSON datasets in PostgreSQL with a weekly refresh cycle.
## Current Status
-**All containers destroyed** and Docker pruned
-**Code is working** - last tested successfully with 5.4M+ cards loaded
- ⏸️ **Project paused** - ready for handoff
## Quick Start for New Thread
```bash
# 1. Read the handoff documentation
cat /home/wall-o/projects/mtgonline/HANDOFF.md
# 2. Check current state
cat /home/wall-o/projects/mtgonline/state.json
# 3. Rebuild and deploy
cd /home/wall-o/projects/mtgonline
docker build -t mtgonline_backend backend/
docker compose -p mtgonline up -d
# 4. Monitor startup
docker logs -f mtgonline_backend
```
## Key Information
**Location**: `/home/wall-o/projects/mtgonline`
**Last Commit**: `ad2742c` - "Fix MTGJSON download: use .gz URLs and handle pre-uncompressed files"
**Core Service**: `backend/app/services/mtgjson_manager.py`
- Downloads MTGJSON data from `https://mtgjson.com/api/v5`
- Handles both gzip-compressed and pre-uncompressed JSON files
- Upserts to PostgreSQL with `ON CONFLICT DO UPDATE`
- Weekly refresh cycle (7 days)
- Container marked unhealthy if data corrupted
**Known Issues Fixed**:
1. MTGJSON URL scheme changed from `.json` to `.json.gz`
2. Some files are pre-uncompressed (not actually gzipped)
3. Large files need 60-minute download timeout
## What to Do Next
1. **Rebuild and test** the deployment
2. **Verify MTGJSON data loads** correctly
3. **Review the codebase** and make any needed improvements
4. **Consider**:
- Incremental updates (vs full refresh)
- Better error handling
- Database optimization
- Monitoring and alerting
## Important Files
- `HANOFOFF.md` - Complete documentation
- `state.json` - Project state
- `backend/app/services/mtgjson_manager.py` - Core MTGJSON integration
- `docker-compose.yml` - Container orchestration
- `.env` - Configuration
## State Tracking
Update `state.json` after each significant change:
```json
{
"task_description": "...",
"current_step": "...",
"files_created": [...],
"files_modified": [...],
"decisions": [...],
"next_steps": [...],
"blockers": [...],
"commit_hash": "...",
"timestamp": ...
}
```
## Git
- Credentials: `/home/wall-o/projects/gitea_credentials.txt`
- Commit after each milestone
- Push changes regularly
+344 -38
View File
@@ -51,100 +51,406 @@ A modern web-based implementation of the MTG Online multiplayer Magic: The Gathe
- [x] Project Roadmap - [x] Project Roadmap
- [x] State tracking - [x] State tracking
## Phase 2: Frontend Development (TODO) ## Phase 2: V1 Backend Deck Building & Card Management (IN PROGRESS)
### 2.1 Project Setup ### 2.0 Project Setup
- [x] Initialize Python project structure
- [x] Create requirements.txt with pinned dependencies
- [x] Set up pydantic-settings configuration
- [x] Configure async SQLAlchemy with PostgreSQL
- [x] Create JWT authentication system with bcrypt
- [x] Set up FastAPI application with CORS
- [x] Fuzzy matching library setup (python-Levenshtein / thefuzz)
### 2.1 Database Models
- [x] User model (accounts, profiles, VIP status)
- [x] Ban model (moderation, history)
- [ ] **NEW: User Deck model** (`user_decks` table)
- `deck_id` (PK, auto-increment)
- `user_id` (FK → users)
- `name` (text)
- `status` (ENUM: DRAFT, FINAL)
- `cards` (JSONB or junction table with card_id, quantity)
- `created_at`, `updated_at` (timestamps)
- `folder_id` (FK → user folders, optional)
- [ ] **NEW: User Card model** (`user_cards` table)
- `user_card_id` (PK, auto-increment)
- `user_id` (FK → users)
- `card_id` (FK → mtg_cards from mtgdata)
- `raw_name` (original name from import file)
- `confidence` (match score from fuzzy search)
- `imported_at` (timestamp)
- `import_id` (FK → import batch)
- [ ] **NEW: Card Import Batch model**
- `import_id` (PK)
- `user_id` (FK → users)
- `file_name` (text)
- `status` (ENUM: PENDING, PROCESSING, COMPLETED, FAILED)
- `total_cards` (int)
- `matched_cards` (int)
- `created_at` (timestamp)
### 2.2 API Endpoints
#### User Deck CRUD
- [ ] `POST /decks/` — Create new draft deck (auto status: DRAFT)
- [ ] `GET /decks/` — List user's decks, filterable by status
- [ ] `GET /decks/{deck_id}` — Get full deck details
- [ ] `PATCH /decks/{deck_id}` — Update deck (name, status, card list)
- [ ] `POST /decks/{deck_id}/finalize` — Transition DRAFT → FINAL
- [ ] `DELETE /decks/{deck_id}` — Delete deck (only if FINAL, or admin override)
- [ ] `GET /decks/{deck_id}/cards` — Get cards in deck with quantity counts
#### Card Search & Suggestions
- [ ] `GET /api/cards/search?q={query}&type={type}&set={set}&color={color}` — Search MTG cards
- [ ] `GET /api/cards/{card_id}` — Get card details
- [ ] `GET /api/sets/` — List available sets
- [ ] `GET /api/cards/suggest?deck_id={deck_id}&limit={n}` — Suggest similar cards
#### Card Import
- [ ] `POST /cards/import` — Upload file (XLSX, CSV, JSON, ODS)
- [ ] `GET /cards/import/{import_id}/status` — Check import progress
- [ ] `GET /cards/import/{import_id}/results` — Get match results with confidence
- [ ] `POST /cards/import/{import_id}/confirm` — Confirm import
- [ ] `GET /user/cards` — List user's imported cards
- [ ] `DELETE /user/cards/{card_import_id}` — Remove from user cards
### 2.3 Services
#### Deck Building Services
- [ ] `services/deck_manager.py` — Deck CRUD operations, status transitions
- [ ] `services/card_search.py` — Card search with filters (name, type, set, color)
- [ ] `services/deck_suggestion.py` — Similar card suggestions based on existing deck
- Match by: same type, same color, same set, same mana cost
- Match by: cards often paired in existing user decks
#### Card Import Services
- [ ] `services/file_parser.py` — Parse XLSX, CSV, JSON, ODS files
- Each row = one card entry (duplicates allowed)
- [ ] `services/fuzzy_card_matcher.py` — Fuzzy string matching service
- Handle spelling errors (e.g., "Wondrrland" → "Wonderland")
- Handle American vs British English (e.g., "color" vs "colour")
- Use python-Levenshtein or thefuzz for matching
- Return confidence scores for each match
- [ ] `services/import_batch_processor.py` — Process import batches
- Batch fuzzy matching for all cards in file
- Update import batch status
- Save matched cards to user_cards table
### 2.4 Fuzzy Matching Implementation
#### Requirements
- Must handle **spelling errors** in card names
- Must handle **American vs British English** differences
- Must return **confidence scores** for match quality
- Must process **bulk imports** efficiently
#### Approach
1. Use `thefuzz` (Python) or `python-Levenshtein` for string matching
2. Implement two-stage matching:
- Stage 1: Exact match (if card name exists exactly)
- Stage 2: Fuzzy match (if no exact match, find best match)
3. Configure similarity threshold (e.g., 85% for auto-accept, 70-84% for manual review)
4. Pre-process card names to normalize:
- Remove extra whitespace
- Normalize punctuation
- Handle known spelling variants
#### Example Match Results
```
Raw: "Wondrrland Explorer"
Match: "Wonderland Explorer" (confidence: 92%)
Raw: "Armour Plated"
Match: "Armor Plated" (confidence: 88%)
Raw: "Colorful Burst"
Match: "Color Burst" (confidence: 85%)
```
### 2.5 Multiplayer Play Backend — Architecture Blueprint (Derived from Cockatrice Analysis)
A detailed architecture analysis of **Cockatrice** (v3.1.0 "Graduation Day") — the mature open-source MTG online client/server — has been completed at `/home/wall-o/projects/mtgonline/C++/ARCHITECTURE_ANALYSIS.md`.
The play backend will follow the same authoritative server model, adapted for a modern web stack:
#### Authority Model
```
┌─────────────────┐ WebSocket (JSON) ┌─────────────────┐
│ CLIENT │◄────────────────────────────────────►│ SERVER │
│ (React/TS) │ │ (FastAPI/Py) │
│ │ │ │
│ • Game Scene │ GameCommands (play, attack, etc.) │ • PostgreSQL │
│ • Hand View │◄────────────────────────────────────►│ • Game State │
│ • Chat Panel │ GameEvents (state changes) │ • Room/Player │
│ • Deck Panel │ │ Management │
│ • Phase Toolbar │ Chat, Admin, Spectator │ • Replay Log │
└─────────────────┘ └─────────────────┘
```
#### Game Engine Architecture
| Cockatrice Component | Modern Web Equivalent | Role |
|---|---|---|
| `AbstractGame` | `Game` (server-side) | Core game instance holding state, players, event handler |
| `GameMetaInfo` | `GameMetadata` | gameId, maxPlayers, description, started, spectators |
| `GameState` | `GameBoardState` | currentPhase, activePlayer, hostId, gameTimer |
| `GameEventHandler` | `GameEventDispatcher` | Central dispatch — processes events, prepares commands (~21KB in Cockatrice) |
| `PlayerManager` | `PlayerRegistry` | Coordinates all players in a game |
| `PlayerLogic` | `Player` | Per-player game logic (~10.7KB in Cockatrice) |
| `PlayerActions` | `PlayerCommands` | Concrete commands: play, attack, tap, draw (~64KB in Cockatrice) |
| `CardZone` | `Zone` (base class) | Abstract zone — Hand/Stack/Table/Graveyard/Exile/Library |
| `HandZone` | `Hand` | Player's hand (secret/hidden zone) |
| `StackZone` | `Stack` | Spells/abilities on the stack |
| `TableZone` | `Battlefield` | Permanents on the battlefield |
| `PileZone` | `Pile` (Graveyard, Exile, Library) | Discard/exile/draw piles |
| `Replay` | `GameReplay` | Serialized event stream for replay |
| `Phase` | `TurnPhase` | 11-phase MTG turn structure |
#### Turn Phase System (11 Phases with Sub-Phases)
```
Untap → Upkeep → Draw → Main 1 → Combat → Main 2 → End → Cleanup
└── Sub-phases:
Beginning of Combat
Declare Attackers
Declare Blockers
Combat Damage
End of Combat
```
#### Command/Event Flow
```
User Action (React component)
GameCommand (JSON message)
GameEventDispatcher.process()
Player.handleCommand()
ZoneLogic (state mutation)
GameEvent (broadcast to all clients via WebSocket)
Client receives & updates UI via Zustand/XState
```
#### Network Protocol Design (Adapted from Cockatrice's protobuf)
Cockatrice uses Protocol Buffers over TCP. The modern web equivalent replaces protobuf with JSON over WebSocket:
| Cockatrice Proto Message | JSON WebSocket Message |
|---|---|
| `ServerInfo_Game` | `{ "type": "game_info", "game_id": 1, "max_players": 2, ... }` |
| `ServerInfo_Player` | `{ "type": "player_info", "player_id": 1, "name": "...", ... }` |
| `Command_PlayCard` | `{ "type": "cmd_play_card", "card_id": 42, "zone": "hand", ... }` |
| `Command_Attack` | `{ "type": "cmd_attack", "attacker_id": 7, "targets": [3, 5], ... }` |
| `Event_Join` | `{ "type": "event_join", "player_id": 2, "properties": {...} }` |
| `Event_Leave` | `{ "type": "event_leave", "player_id": 1, "reason": "..." }` |
| `Event_SetActivePlayer` | `{ "type": "event_active_player", "player_id": 1 }` |
| `Event_SetActivePhase` | `{ "type": "event_active_phase", "phase": 5 }` |
| `Event_GameSay` | `{ "type": "event_chat", "player_id": 1, "message": "..." }` |
| `GameReplay` | `{ "type": "replay", "events": [...] }` |
#### Architecture Patterns to Reuse
| Pattern | Cockatrice Usage | Modern Equivalent |
|---|---|---|
| **Event Bus** | Qt signals/slots | WebSocket broadcast + Zustand stores |
| **Command Pattern** | `Command_PlayCard`, `Command_Attack` | JSON command messages, validated server-side |
| **Observer** | `GameEventHandler` emits signals | WebSocket events trigger UI updates |
| **Strategy** | `CardZoneLogic` subclasses | Zone classes with strategy pattern (Hand, Stack, Table, Pile, View) |
| **Facade** | `AbstractGame` | Single `Game` object wrapping all subsystems |
| **Memento** | `DeckListMemento` for undo | Immutable state snapshots for undo/redo |
| **Repository** | `ServatriceDatabaseInterface` | SQLAlchemy repositories for game state, decks, logs |
#### Key Decisions (Informed by Cockatrice Analysis)
1. **Server is authoritative** — clients send commands, server validates and broadcasts events. No client-side state manipulation.
2. **Deterministic replay** — serialize all game events; same command sequence produces identical state.
3. **Zone abstraction** — each zone type (Hand, Stack, Battlefield, Pile) is independently implementable.
4. **Phase system** — 11-phase MTG turn with sub-phases, tracked server-side.
5. **Command/Event separation** — what a player *wants* to do vs. what *happens*.
#### What Modernizes Cockatrice
| Cockatrice Limitation | Modern Web Solution |
|---|---|
| TCP only, no web protocol | WebSocket (JSON) |
| Qt Widgets desktop UI | React/Next.js with PixiJS for game board |
| C++/MySQL backend | Python/FastAPI + PostgreSQL |
| Single-server clustering | Stateless game servers + Redis state cache |
| No mobile support | Responsive design from start |
| No REST API | FastAPI REST + WebSocket hybrid |
| Protobuf serialization | JSON over WebSocket |
| 106KB monolithic server handler | Modular service architecture |
### 2.6 Play Backend Responsibilities
The play backend will implement:
- [ ] Real-time game state management (server-authoritative)
- [ ] Multiplayer WebSocket communication
- [ ] Game rule enforcement (combat, stack resolution, priority)
- [ ] Deck validation during gameplay
- [ ] Game history and replay (serialized event log)
- [ ] Room and game lobbies
- [ ] Spectator mode
- [ ] Admin commands (kick, ban, game control)
**Note**: The play backend lives in a separate codebase. Integration points with the card backend:
- **Card Backend API** (`http://backend:8000`):
- Authentication via JWT
- Card lookups: `GET /api/cards/{card_id}`
- Card search: `GET /api/cards/search?q=...`
- Set lists: `GET /api/sets/`
- User decks: `GET /api/users/{user_id}/decks`
- **Direct PSQL Access**:
- `mtgdata` database: Read card data, sets, etc.
- `mtgonline` database: Read user decks, validate deck legality
- Normalize punctuation
- Handle known spelling variants
#### Example Match Results
```
Raw: "Wondrrland Explorer"
Match: "Wonderland Explorer" (confidence: 92%)
Raw: "Armour Plated"
Match: "Armor Plated" (confidence: 88%)
Raw: "Colorful Burst"
Match: "Color Burst" (confidence: 85%)
```
### 2.5 Multiplayer Play Backend (SEPARATE CODEBASE)
#### Architecture
The multiplayer gameplay feature will live in a **separate backend codebase** to ensure smooth, independent development.
#### Integration Points
- **Card Backend API** (`http://backend:8000`):
- Authentication via JWT
- Card lookups: `GET /api/cards/{card_id}`
- Card search: `GET /api/cards/search?q=...`
- Set lists: `GET /api/sets/`
- User decks: `GET /api/users/{user_id}/decks`
- **Direct PSQL Access**:
- `mtgdata` database: Read card data, sets, etc.
- `mtgonline` database: Read user decks, validate deck legality
#### Play Backend Responsibilities
- Real-time game state management
- Multiplayer WebSocket communication
- Game rule enforcement
- Deck validation during gameplay
- Game history and replay
**Note**: This backend is out of scope for the current codebase. See separate repository when ready.
### 2.6 Testing
- [ ] Unit tests for deck CRUD operations
- [ ] Unit tests for card search functionality
- [ ] Unit tests for card suggestion algorithm
- [ ] Unit tests for file parsers (XLSX, CSV, JSON, ODS)
- [ ] Unit tests for fuzzy matching service
- [ ] Integration tests for import workflow
- [ ] Load tests for bulk import processing
### 2.7 Documentation
- [ ] API documentation (FastAPI auto-generated)
- [ ] Database schema documentation
- [ ] Fuzzy matching algorithm documentation
- [ ] Import workflow documentation
- [ ] Play backend integration guide
## Phase 3: Frontend Development (TODO)
### 3.1 Project Setup
- [ ] Initialize React + TypeScript project with Vite - [ ] Initialize React + TypeScript project with Vite
- [ ] Configure ESLint, Prettier, TypeScript strict mode - [ ] Configure ESLint, Prettier, TypeScript strict mode
- [ ] Set up Zustand for state management - [ ] Set up Zustand for state management
- [ ] Configure Tailwind CSS for styling - [ ] Configure Tailwind CSS for styling
- [ ] Set up Vitest + React Testing Library - [ ] Set up Vitest + React Testing Library
### 2.2 Authentication ### 3.2 Authentication
- [ ] Login form with JWT token storage - [ ] Login form with JWT token storage
- [ ] Registration form with validation - [ ] Registration form with validation
- [ ] Protected routes and auth context - [ ] Protected routes and auth context
- [ ] Session management and token refresh - [ ] Session management and token refresh
### 2.3 Deck Builder ### 3.3 Deck Builder
- [ ] Card search with filters (name, color, type, set) - [ ] Card search with filters (name, color, type, set)
- [ ] Deck list editor with drag-and-drop - [ ] Deck list editor with drag-and-drop
- [ ] Import/export deck formats (plain text, native XML) - [ ] Import/export deck formats (plain text, native XML)
- [ ] Folder management UI - [ ] Folder management UI
- [ ] Real-time deck statistics (card count, mana curve) - [ ] Real-time deck statistics (card count, mana curve)
- [ ] **NEW: Deck status indicator** (DRAFT vs FINAL)
- [ ] **NEW: Card suggestion panel** (shows similar cards)
### 2.4 Game Interface ### 3.4 Card Import Interface
- [ ] File upload component (XLSX, CSV, JSON, ODS)
- [ ] Import progress indicator
- [ ] Match results display with confidence scores
- [ ] Manual override for low-confidence matches
- [ ] Import history and re-import capability
### 3.5 Game Interface (TODO - Dependent on Play Backend)
- [ ] Game board visualization (zones, cards) - [ ] Game board visualization (zones, cards)
- [ ] Player hand (private zone) - [ ] Player hand (private zone)
- [ ] Library, graveyard, exile, command zones - [ ] Library, graveyard, exile, command zones
- [ ] Card interaction (click, drag, hover) - [ ] Card interaction (click, drag, hover)
- [ ] Real-time WebSocket updates - [ ] Real-time WebSocket updates
### 2.5 Chat System ### 3.6 Chat System
- [ ] Room chat interface - [ ] Room chat interface
- [ ] Game chat (in-game messaging) - [ ] Game chat (in-game messaging)
- [ ] Player list display - [ ] Player list display
- [ ] Moderator tools (kick, ban) - [ ] Moderator tools (kick, ban)
### 2.6 Admin Dashboard ### 3.7 Admin Dashboard
- [ ] User management interface - [ ] User management interface
- [ ] Ban/unban controls - [ ] Ban/unban controls
- [ ] Game logs viewer - [ ] Game logs viewer
- [ ] System statistics - [ ] System statistics
## Phase 3: Integration & Polish (TODO) ## Phase 4: Integration & Polish (TODO)
### 3.1 WebSocket Client ### 4.1 WebSocket Client
- [ ] WebSocket connection management - [ ] WebSocket connection management
- [ ] Reconnection logic with exponential backoff - [ ] Reconnection logic with exponential backoff
- [ ] Message serialization/deserialization - [ ] Message serialization/deserialization
- [ ] Protocol buffer message handling - [ ] Protocol buffer message handling
### 3.2 Card Database ### 4.2 Card Database
- [ ] Import MTJSON card data - [ ] Import MTJSON card data
- [ ] Cache card images locally - [ ] Cache card images locally
- [ ] Search and filter functionality - [ ] Search and filter functionality
- [ ] Card tooltips with oracle text - [ ] Card tooltips with oracle text
### 3.3 Game Logic ### 4.3 Game Logic (TODO - Dependent on Play Backend)
- [ ] Turn-based state management - [ ] Turn-based state management
- [ ] Priority system implementation - [ ] Priority system implementation
- [ ] Stack resolution - [ ] Stack resolution
- [ ] Mana payment tracking - [ ] Mana payment tracking
- [ ] Life totals and counters - [ ] Life totals and counters
### 3.4 Performance ### 4.4 Performance
- [ ] Virtual scrolling for card lists - [ ] Virtual scrolling for card lists
- [ ] Memoization and React.memo - [ ] Memoization and React.memo
- [ ] Code splitting and lazy loading - [ ] Code splitting and lazy loading
- [ ] WebSocket message batching - [ ] WebSocket message batching
## Phase 4: Deployment & Production (TODO)
### 4.1 DevOps
- [ ] Docker Compose for development
- [ ] Production Docker images
- [ ] CI/CD pipeline (GitHub Actions)
- [ ] Environment configuration management
### 4.2 Security
- [ ] Rate limiting
- [ ] Input validation and sanitization
- [ ] CORS configuration
- [ ] HTTPS/SSL configuration
### 4.3 Monitoring
- [ ] Logging with structured formats
- [ ] Error tracking (Sentry)
- [ ] Performance monitoring
- [ ] Uptime monitoring
### 4.4 Documentation
- [ ] User documentation
- [ ] Developer documentation
- [ ] API documentation
- [ ] Architecture documentation
## Phase 5: Advanced Features (FUTURE) ## Phase 5: Advanced Features (FUTURE)
### 5.1 Multiplayer Enhancements ### 5.1 Multiplayer Enhancements
-115
View File
@@ -1,115 +0,0 @@
# Backend Testing Summary
## Overview
Successfully fixed and completed the backend test suite for the mtgonline project. All **20 tests** are now passing.
## Test Results
```
======================= 20 passed, 57 warnings in 5.20s ========================
```
### Test Breakdown
- **Admin Tests**: 5/5 passing
- `test_list_users_admin`
- `test_list_users_non_admin`
- `test_create_ban`
- `test_list_bans`
- `test_unban_user`
- **Authentication Tests**: 7/7 passing
- `test_login_success`
- `test_login_invalid_password`
- `test_login_nonexistent_user`
- `test_register_success`
- `test_register_duplicate_username`
- `test_get_current_user`
- `test_refresh_token`
- **Deck Management Tests**: 8/8 passing
- `test_create_deck`
- `test_list_decks`
- `test_get_deck`
- `test_update_deck`
- `test_delete_deck`
- `test_create_folder`
- `test_list_folders`
- `test_delete_folder`
## Key Changes Made
### 1. JWT Token Updates (`app/core/security.py`)
- Added `privlevel` field to JWT access tokens
- Updated `get_current_user()` to extract `privlevel` from token
- Updated `create_access_token()` to accept `privlevel` parameter
### 2. Test Fixtures (`tests/conftest.py`)
- Fixed `client` fixture to share database session with test fixtures
- Used `hash_password()` for proper bcrypt password hashing
- Updated fixture scope from `session` to `function` for isolation
- Properly cleaned up dependency overrides
### 3. Router Fixes (`app/routers/decks.py`)
- Reordered routes to prevent `/folders` from matching `/{deck_id}`
- Routes now checked in correct order: specific routes first, then parameterized
### 4. Database Model Updates (`app/models/models.py`)
- Made `folder_id` in `DecklistFile` nullable (optional at creation)
### 5. Schema Updates (`app/schemas/schemas.py`)
- Removed relationship fields from `FolderResponse` to avoid async context issues
- Simplified schema to only include direct fields
## Architecture Notes
### Authentication Flow
```
Login → JWT Token (includes privlevel) → Authorization checks
```
### Test Database Setup
- Uses in-memory SQLite (`sqlite+aiosqlite:///:memory:`)
- Each test function gets isolated database state
- Shared session via dependency override
### Security Features Tested
- Password hashing with bcrypt
- JWT token validation
- Admin privilege checks (privlevel-based authorization)
- Duplicate username/email prevention
- Token refresh mechanism
## Repository Information
- **Repository**: `https://git.optimex.systems/admin/mtgonline.git`
- **Branch**: `main`
- **Latest Commit**: `167a352`
- **Status**: Backend test suite complete and ready for frontend development
## Next Steps
1. ✅ Backend test suite complete
2. ⏳ Frontend development (Next.js)
3. ⏳ API integration testing
4. ⏳ Deployment setup
## Files Modified
- `app/core/security.py`
- `app/models/models.py`
- `app/routers/auth.py`
- `app/routers/decks.py`
- `app/schemas/schemas.py`
- `tests/conftest.py`
- `tests/test_admin.py`
- `tests/test_auth.py`
- `tests/test_decks.py`
## Commands
```bash
# Run all tests
cd /home/wall-o/projects/mtgonline/backend
/home/wall-o/workspace/venv/bin/python -m pytest tests/ -v
# Run specific test file
/home/wall-o/workspace/venv/bin/python -m pytest tests/test_auth.py -v
# Run with verbose output
/home/wall-o/workspace/venv/bin/python -m pytest tests/ -v --tb=short
```
-103
View File
@@ -1,103 +0,0 @@
# Backend System Test Prompt
## Project Context
You are working on the **mtgonline** backend project located at `/home/wall-o/projects/mtgonline/backend/`.
The backend is a FastAPI application with:
- PostgreSQL database (MTG Online + MTG data)
- Redis caching
- JWT authentication
- MTG card search and statistics endpoints
## Current State
Read the `state.json` file at `/home/wall-o/projects/mtgonline/backend/state.json` to understand:
- What has been completed
- What the current focus is
- Any blockers or pending tasks
## Task: Full Backend System Test
Your goal is to perform a comprehensive system test of the backend to verify all components work correctly together.
### Steps to Follow:
1. **Read State File**
- Read `/home/wall-o/projects/mtgonline/backend/state.json`
- Understand current progress and what's been tested
2. **Check Docker Containers**
- Run: `cd /home/wall-o/projects/mtgonline && docker compose ps`
- If containers aren't running, start them: `docker compose up -d`
- Wait for services to be healthy (~40 seconds)
3. **Run System Test**
- Execute: `cd /home/wall-o/projects/mtgonline/backend && python test_system.py`
- This tests:
- Database connections (PostgreSQL + Redis)
- Cache operations
- Database tables
- API endpoints (health, card search, statistics, auth)
4. **Analyze Results**
- If tests fail, investigate and fix issues
- Common issues:
- Database not running
- Redis connection failed
- API routes not registered
- Environment variables not set
5. **Update State**
- Update `state.json` with test results
- Note any bugs found and fixed
- Document what's working and what needs attention
6. **Report Findings**
- Summarize test results (pass/fail rates)
- List any issues found
- Recommend next steps
## Expected Test Coverage
The system test (`test_system.py`) should verify:
- ✅ MTG Online PostgreSQL connection
- ✅ MTG PostgreSQL connection
- ✅ Redis connection and operations
- ✅ MTG Online database tables (users, decks)
- ✅ MTG database tables (sets, cards)
- ✅ MTG card search functionality
- ✅ Health endpoint
- ✅ Card search API endpoint
- ✅ Statistics API endpoint
- ✅ Authentication endpoint
## Success Criteria
All tests should pass (100% success rate) before considering the backend ready for frontend development.
## Commands Reference
```bash
# Check Docker status
cd /home/wall-o/projects/mtgonline && docker compose ps
# Start containers
cd /home/wall-o/projects/mtgonline && docker compose up -d
# Wait for health (40 seconds)
sleep 40
# Run system test
cd /home/wall-o/projects/mtgonline/backend && python test_system.py
# Check logs if issues
cd /home/wall-o/projects/mtgonline && docker compose logs backend
```
## Important Notes
- All code execution must be as user `wall-o` (not root)
- Use sudo -u wall-o when running commands
- Pin versions in requirements.txt
- Follow PEP 8 for Python code
- Log errors with context
- One change per commit, descriptive messages
-120
View File
@@ -1,120 +0,0 @@
# MTG Online Backend - Context for Continuation
## Current State
I just completed fixing 10 issues with the MTG Online backend codebase. All fixes are in place but **have not yet been deployed or tested**.
## What Was Fixed
### Files Modified/Created:
1. **`app/routers/interactions.py`** - NEW - Comprehensive interaction search endpoints
2. **`app/main.py`** - REWRITTEN - Fixed router mounting and logging
3. **`app/routers/__init__.py`** - REWRITTEN - Added all router exports
4. **`Dockerfile`** - REWRITTEN - Added scripts directory and permissions
### Key Changes:
- All 8 routers now properly mounted in FastAPI
- Interaction endpoints: synergies, counters, evolutions, recommendations, search, stats
- Verbose logging with debug support
- Consistent async database usage
- Redis caching for performance
- Proper error handling throughout
## Next Steps - Execute These Commands
### Step 1: Stop all containers
```bash
cd /home/wall-o/projects/mtgonline
docker compose down -v
```
### Step 2: Build the backend
```bash
cd /home/wall-o/projects/mtgonline
docker compose build backend
```
### Step 3: Deploy the stack
```bash
cd /home/wall-o/projects/mtgonline
docker compose up -d
```
### Step 4: Wait for containers to start
```bash
# Watch logs in real-time
docker compose logs -f backend
```
### Step 5: Verify health
```bash
# Check all containers
docker compose ps
# Test health endpoint
curl -s http://localhost:5555/health | python -m json.tool
# Check API docs are accessible
curl -s http://localhost:5555/docs | head -20
```
### Step 6: Verify interaction endpoints
```bash
# List all registered routes
curl -s http://localhost:5555/openapi.json | python -m json.tool | grep -E '"path":|"/(interactions|cards|auth|users|decks|rooms|games|admin)"'
# Test interaction search endpoint
curl -s "http://localhost:5555/interactions/search/synergies?limit=5" | python -m json.tool
```
## Expected Success Indicators
- All containers show `healthy` status
- `/health` returns `{"status": "healthy", "version": "0.2.0"}`
- `/docs` returns OpenAPI JSON
- All routers appear in `/openapi.json`
- No Python import errors in logs
- Database connections successful
- Redis connection successful
## Troubleshooting
### If container fails to start:
```bash
# Check exit codes
docker compose ps
# View recent logs
docker compose logs --tail=100 backend
# Check if port 5555 is in use
sudo lsof -i :5555
```
### Common Issues:
1. **Import errors**: Check that all router files exist and have proper syntax
2. **Database connection**: Verify `.env` has correct database URLs
3. **Port conflicts**: Ensure no other service uses port 5555
4. **Permission issues**: Dockerfile should run as `appuser` not root
### To view database state:
```bash
# Connect to card database
docker exec -it mtgonline_db_card psql -U mtgonline -d mtgdata -c "\dt"
# Check tables
docker exec -it mtgonline_db_card psql -U mtgonline -d mtgdata -c "SELECT count(*) FROM mtg_cards;"
```
## Important Context
- Backend port: **5555** (not 8000!)
- Two PostgreSQL containers: `mtgdata` and `users`
- Redis for caching
- All code runs as `wall-o` user (UID 1001)
- Python venv at `/home/wall-o/workspace/venv`
## Files to Review if Issues Arise
- `/home/wall-o/projects/mtgonline/backend/app/main.py` - Router mounting
- `/home/wall-o/projects/mtgonline/backend/app/routers/interactions.py` - New endpoints
- `/home/wall-o/projects/mtgonline/backend/app/routers/__init__.py` - Exports
- `/home/wall-o/projects/mtgonline/backend/Dockerfile` - Container setup
- `/home/wall-o/projects/mtgonline/.env` - Configuration
- `/home/wall-o/projects/mtgonline/docker-compose.yml` - Stack definition
-132
View File
@@ -1,132 +0,0 @@
# MTG Online Backend - Ported State and Next Steps
## Project Overview
The `mtgonline` project is a Magic: The Gathering online application with a Docker-based stack:
- Two PostgreSQL containers (card data + user data)
- Backend application on port 5555
- MTGJSON data loading pipeline
## Recent Work Summary
### What Was Done
1. **Created comprehensive interaction router** (`backend/app/routers/interactions.py`)
- Synergies search with filters (type, strength, confidence, pagination)
- Counters search with filters
- Evolutions search with filters
- Card recommendations (synergy, counter, evolution types)
- Card interaction statistics
- Redis caching for performance
2. **Fixed main.py** to properly mount all routers
- Removed duplicate search implementation
- Added all routers: auth, users, decks, rooms, games, admin, card_router, interactions
- Added verbose logging configuration
- Added lifespan events for startup/shutdown
3. **Updated __init__.py** to export all routers
- Centralized router imports
- Proper package structure
4. **Updated Dockerfile** to include interaction scripts
- Added scripts directory to container
- Made scripts executable
- Proper permissions for appuser
### Files Modified/Created
- `backend/app/routers/interactions.py` - NEW
- `backend/app/main.py` - REWRITTEN
- `backend/app/routers/__init__.py` - REWRITTEN
- `backend/Dockerfile` - REWRITTEN
### Key Technical Decisions
- All interactions use async SQLAlchemy with mtg_get_db dependency
- Redis caching with 10-30 minute TTLs
- Proper error handling with HTTPException
- Consistent database connection pattern across all endpoints
- Logging setup with debug/verbose support
## Next Steps (Execute in Order)
### 1. Stop and Destroy All Docker Containers
```bash
cd /home/wall-o/projects/mtgonline
docker compose down -v
```
### 2. Build the Backend Docker Container
```bash
cd /home/wall-o/projects/mtgonline
docker compose build backend
```
### 3. Deploy the Stack as a Test Instance
```bash
cd /home/wall-o/projects/mtgonline
docker compose up -d
```
### 4. Verify Stack Health
```bash
# Check all containers are running
docker compose ps
# Check backend health endpoint
curl http://localhost:5555/health
```
### 5. Check Logs for Issues
```bash
# View backend logs
docker compose logs backend
# View PostgreSQL logs if needed
docker compose logs db_card
docker compose logs db_user
```
### 6. Troubleshoot Issues
If errors are found:
- **Import errors**: Check that all router modules exist and are properly imported
- **Database connection errors**: Verify `.env` file has correct database URLs
- **Port conflicts**: Ensure port 5555 is available
- **Permission errors**: Check Dockerfile has proper user/permissions setup
## Commands to Monitor Progress
```bash
# Watch logs in real-time
docker compose logs -f backend
# Check container status
docker compose ps
# Restart specific container
docker compose restart backend
# View specific container logs
docker compose logs --tail=50 backend
```
## Key Configuration
- Backend port: 5555
- Database URLs in `.env` file
- Two PostgreSQL databases: `mtgdata` (card data) and `users` (user data)
- Redis for caching
- All routers mounted in `app/main.py`
## Expected Behavior
Once healthy, the backend should:
- Serve API documentation at `/docs`
- Respond to health checks at `/health`
- Have all interaction endpoints available at `/interactions/*`
- Show proper logging output indicating successful startup
## Error Resolution Strategy
1. **Simple errors**: Fix directly (typos, import paths, missing dependencies)
2. **Complex issues**: Document the problem, check Docker logs for stack traces, and consult with user
3. **Database issues**: Verify connection strings, check PostgreSQL logs, ensure databases exist
## Important Notes
- All code execution must be as wall-o user (not root)
- Use `/home/wall-o/workspace/venv` for Python dependencies
- Docker commands should be run from `/home/wall-o/projects/mtgonline`
- The `.env` file is separate from application config
+74 -241
View File
@@ -1,271 +1,104 @@
# MTG Online Backend # MTG Online Backend
Python FastAPI application for processing MTGJSON card data and managing the MTG Online platform backend. A Python FastAPI application that processes Magic: The Gathering card data from [MTGJSON](https://mtgjson.com/) and stores it in PostgreSQL, with Redis for caching.
## Overview
This project provides a backend API for a Magic: The Gathering Online platform. It downloads and processes MTGJSON v5 dataset dumps, loads them into a PostgreSQL database, and exposes REST endpoints for card data, user authentication, deck management, and game state.
### Key Features
- **MTGJSON Data Pipeline** — Downloads `AllPrintings.psql`, `AllIdentifiers.json`, `Keywords.json`, `CardTypes.json`, and `AllDeckFiles.zip` from MTGJSON v5 on startup or via a `POST /refresh` endpoint.
- **Dual PostgreSQL** — Two databases: `mtgonline` for the application (users, decks, auth) and `mtgdata` for MTG card data.
- **Redis Caching** — Used for card lookup caching and interaction pipeline state.
- **REST API** — `/docs` (Swagger) available at runtime.
## Architecture ## Architecture
### Core Components ```
mtgonline/
The backend consists of several key layers: ├── backend/ # FastAPI application
│ ├── app/
**Core Layer** (`app/core/`) │ │ ├── core/ # Settings, database engines, Redis client
- `settings.py` — Application configuration using pydantic-settings with environment variable overrides │ │ ├── models/ # SQLAlchemy ORM models (app + MTG)
- `database.py` — Dual PostgreSQL engine setup (mtgonline app DB + mtgdata MTG cards DB) │ │ ├── routers/ # API route modules
- `redis_client.py` — Redis connection and caching utilities │ │ ├── schemas/ # Pydantic request/response schemas
│ │ ├── services/ # Business logic (MTGJSON manager, card DB, game server)
**Models** (`app/models/`) │ │ └── main.py # FastAPI app entry point
- `models.py` — SQLAlchemy ORM models for application data (users, decks, auth) │ ├── scripts/ # Utility scripts (downloads, migrations, checks)
- `mtg_models.py` — ORM models for MTG card data │ ├── Dockerfile
│ └── requirements.txt
**Services** (`app/services/`) ├── docker-compose.dev.yml # Development stack (Postgres x2, Redis, Backend)
- `mtgjson_manager.py` — Primary MTGJSON data pipeline (download, unzip, upsert) ├── docker-compose.yml # Production stack
- `mtgjson_downloader.py` — HTTP client for MTGJSON API ├── scripts/ # Shared utility scripts
- `mtgjson_loader.py` — Data loading and transformation └── README.md
- `mtgjson_uploader.py` — Database upsert operations ```
- `card_database.py` — Card data access layer
- `game_server.py` — Game state management
- `deck_parser.py` — Deck list parsing and validation
**Routers** (`app/routers/`)
- `auth.py` — JWT authentication endpoints
- `users.py` — User management
- `decks.py` — Deck CRUD operations
- `rooms.py` — Game room management
- `games.py` — Game state endpoints
- `admin.py` — Admin tools
- `card_router.py` — MTG card data API
- `interactions.py` — Card interaction engine
- `refresh.py` — MTGJSON data refresh endpoint
- `ws.py` — WebSocket support
**Schemas** (`app/schemas/`)
- `schemas.py` — Pydantic models for request/response validation
- `proto_messages.py` — Protocol buffer message definitions
- `protocol_constants.py` — MTG protocol constants
### Data Flow ### Data Flow
``` 1. **On startup**, the backend connects to PostgreSQL (both instances) and Redis.
MTGJSON API (https://mtgjson.com/api/v5/) 2. It checks `mtg_refresh_log` in the `mtgdata` database for existing data.
↓ download 3. If no data exists, it downloads MTGJSON files from `https://mtgjson.com/api/v5/`, unzips if needed, and upserts them into `mtgdata` tables (`mtg_sets`, `mtg_cards`, etc.).
MTGJSON files (AllPrintings.psql, AllIdentifiers.json, etc.) 4. The data is then available via REST endpoints.
↓ load/transform
PostgreSQL (mtgdata database) ## Quick Start
↓ query
REST API / Swagger Docs ### Prerequisites
- Docker and Docker Compose
### Run the Stack
```bash
cd /home/wall-o/projects/mtgonline
# Start all services (Postgres x2, Redis, Backend)
docker compose -f docker-compose.dev.yml up -d
# View logs
docker compose -f docker-compose.dev.yml logs -f backend
``` ```
## Database Setup The backend will automatically download MTGJSON data on first startup (this may take several minutes).
### Primary Database (mtgonline) ### Access
- **Purpose**: Application data (users, decks, auth tokens)
- **Connection**: `postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline`
- **Port**: 5432 (internal), 5432:5432 (host)
### MTG Data Database (mtgdata) | Service | Address |
- **Purpose**: MTG card data, sets, refresh logs |---------------|---------------------|
- **Connection**: `postgresql+asyncpg://mtgonline_user:mtgonline_password@mtgdata:5432/mtgdata` | Backend API | `http://localhost:5555` |
- **Port**: 5432 (internal), 5433:5432 (host) | Swagger Docs | `http://localhost:5555/docs` |
- **Tables**: | Health Check | `http://localhost:5555/health` |
- `mtg_sets` — Card sets metadata | PostgreSQL (app) | `localhost:5432` |
- `mtg_cards` — Individual card data | PostgreSQL (MTG) | `localhost:5433` |
- `mtg_refresh_log` — Refresh history and status | Redis | `localhost:6379` |
### Redis ### Manual Data Refresh
- **Purpose**: Caching, session management
- **Connection**: `redis://redis:6379`
- **Port**: 6379 (internal), 6379:6379 (host)
## MTGJSON Data Pipeline ```bash
# Trigger a manual MTGJSON refresh
### Downloaded Files curl -X POST http://localhost:5555/refresh
```
The backend downloads these files from MTGJSON v5:
- `AllPrintings.psql` — Main card data (PostgreSQL format)
- `AllIdentifiers.json` — Card identifiers (Multiverse, Scryfall, etc.)
- `Keywords.json` — Card keywords
- `CardTypes.json` — Card type definitions
- `AllDeckFiles.zip` — Deck files (must be unzipped)
### Refresh Logic
1. **On Startup**: Checks `mtg_refresh_log` for existing data
2. **If No Data**: Downloads and loads all MTGJSON files (may take minutes)
3. **Manual Refresh**: `POST /refresh` endpoint triggers immediate reload
4. **Logging**: All refreshes logged to `mtg_refresh_log` with status, timing, and counts
## Environment Variables ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `DATABASE_URL` | `postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline` | Primary database | | `DATABASE_URL` | `postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline` | Primary database connection |
| `MTG_DATABASE_URL` | `postgresql+asyncpg://mtgonline_user:mtgonline_password@mtgdata:5432/mtgdata` | MTG data database | | `MTG_DATABASE_URL` | `postgresql+asyncpg://mtgonline_user:mtgonline_password@mtgdata:5432/mtgdata` | MTG data database connection |
| `REDIS_URL` | `redis://redis:6379` | Redis connection | | `REDIS_URL` | `redis://redis:6379` | Redis connection |
| `DATA_DIR` | `/app/data` | MTGJSON files directory | | `DATA_DIR` | `/app/data` | Directory for MTGJSON files |
| `UPLOAD_DIR` | `/app/uploads` | User uploads directory |
| `DEBUG` | `False` | Enable debug logging | | `DEBUG` | `False` | Enable debug logging |
| `LOG_LEVEL` | `INFO` | Logging level |
| `SECRET_KEY` | `change-me-in-production` | JWT secret |
| `JWT_SECRET_KEY` | `change-me-in-production` | JWT signing key |
## Running the Backend ## Docker Cleanup
### Docker (Recommended)
```bash ```bash
# Build image # Stop and remove all containers
cd backend docker compose -f docker-compose.dev.yml down
docker build -t mtgonline-backend:latest .
# Run with dependencies # Remove images and prune
docker compose -f ../docker-compose.dev.yml up -d backend docker system prune -a --volumes
``` ```
### Local Development
```bash
cd backend
# Create venv
python -m venv venv
source venv/bin/activate
# Install dependencies
pip install -r requirements.txt
# Set environment variables
export DATABASE_URL="postgresql+asyncpg://mtgonline_user:mtgonline_password@localhost:5432/mtgonline"
export MTG_DATABASE_URL="postgresql+asyncpg://mtgonline_user:mtgonline_password@localhost:5433/mtgdata"
export REDIS_URL="redis://localhost:6379"
# Run server
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
## API Endpoints
### Health & Status
- `GET /health` — Health check with MTGJSON status
- `GET /` — API info
### Authentication
- `POST /auth/login` — User login
- `POST /auth/register` — User registration
- `POST /auth/refresh` — Refresh JWT
- `GET /auth/me` — Current user
### Users
- `GET /users/{user_id}` — Get user
- `PATCH /users/{user_id}` — Update user
- `POST /users/{user_id}/ban` — Ban user (admin)
### Decks
- `GET /decks/` — List decks
- `POST /decks/` — Create deck
- `GET /decks/{deck_id}` — Get deck
- `PATCH /decks/{deck_id}` — Update deck
- `DELETE /decks/{deck_id}` — Delete deck
### MTG Cards
- `GET /api/cards/` — Search cards
- `GET /api/cards/{card_id}` — Get card
- `GET /api/sets/` — List sets
### Admin
- `GET /admin/users` — List all users
- `GET /admin/bans` — List bans
- `POST /admin/bans` — Create ban
### Data Management
- `POST /refresh` — Trigger MTGJSON refresh
### WebSocket
- `WS /ws/{room_id}` — Real-time game communication
## Utility Scripts
Located in `scripts/`:
- `download_mtgjson.py` — Manual MTGJSON download
- `check_mtgjson_status.py` — Verify data freshness
- `verify_mtgjson_data.py` — Data validation
- `inspect_db.py` — Database inspection
- `load_mtgjson_data.py` — Data loading
## Testing
```bash
cd backend
# Run tests
pytest
# Run with coverage
pytest --cov=app --cov-report=html
```
## Project Structure
```
backend/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app entry
│ ├── core/
│ │ ├── settings.py # Configuration
│ │ ├── database.py # Database engines
│ │ └── redis_client.py # Redis utilities
│ ├── models/
│ │ ├── models.py # App models
│ │ └── mtg_models.py # MTG models
│ ├── routers/
│ │ ├── auth.py # Auth endpoints
│ │ ├── users.py # User endpoints
│ │ ├── decks.py # Deck endpoints
│ │ ├── rooms.py # Room endpoints
│ │ ├── games.py # Game endpoints
│ │ ├── admin.py # Admin endpoints
│ │ ├── card_router.py # Card API
│ │ ├── interactions.py # Card interactions
│ │ ├── refresh.py # Data refresh
│ │ └── ws.py # WebSocket
│ ├── schemas/
│ │ ├── schemas.py # Pydantic models
│ │ ├── proto_messages.py # Protocol messages
│ │ └── protocol_constants.py
│ └── services/
│ ├── mtgjson_manager.py # MTGJSON pipeline
│ ├── mtgjson_downloader.py
│ ├── mtgjson_loader.py
│ ├── mtgjson_uploader.py
│ ├── card_database.py # Card data access
│ ├── game_server.py # Game logic
│ └── deck_parser.py # Deck parsing
├── scripts/ # Utility scripts
├── tests/ # Test suite
├── Dockerfile # Container build
├── requirements.txt # Python dependencies
├── pyproject.toml # Ruff config
├── .env.example # Environment template
└── setup_db.py # Database setup script
```
## Troubleshooting
### Backend can't connect to databases
- Verify all services are running: `docker compose -f ../docker-compose.dev.yml ps`
- Check logs: `docker compose -f ../docker-compose.dev.yml logs backend`
- Ensure environment variables match docker-compose.dev.yml
### MTGJSON download fails
- Check network connectivity to mtgjson.com
- Verify DATA_DIR has write permissions
- Check disk space: `df -h`
- Manual download: `python scripts/download_mtgjson.py`
### Database tables missing
- Run initialization: `docker exec -i mtgdata psql -U mtgonline_user mtgdata < /path/to/scripts/init-mtgdata.sql`
- Check tables: `docker exec mtgdata psql -U mtgonline_user mtgdata -c "\dt"`
## License ## License
MIT MIT
-82
View File
@@ -1,82 +0,0 @@
# Technical Specification: MTG Synergy Mapping Engine
## 1. Project Overview
The goal is to create a Python-based data pipeline that processes MTG card data from MTGJSON, identifies synergistic relationships between cards, and stores these relationships in a PostgreSQL database. This "Data Map" will power a deck-building assistant that suggests cards based on mechanical and strategic complementarity.
## 2. Tech Stack
- **Language:** Python 3.12+
- **Libraries:** `pandas` (data manipulation), `SQLAlchemy` (ORM), `psycopg2` (DB driver), `re` (regex for text processing).
- **Database:** PostgreSQL.
- **Data Source:** MTGJSON (`AllPrintings.json`, `AllSets.json`).
## 3. Phase 1: Data Ingestion & Normalization
The script must flatten the nested MTGJSON structure into a relational format.
### 3.1 Extraction
Extract the following fields from `AllPrintings.json`:
- `name`, `manaCost`, `types`, `text` (oracle text), `colorIdentity`, `set`.
### 3.2 Text Processing (`TextProcessor` Class)
Implement a class to convert raw oracle text into "Functional Tokens."
- **Regex Mapping:** Use a dictionary of regex patterns to identify key actions.
- *Example:* `"draw a card"` $\rightarrow$ `TOKEN_DRAW_1`
- *Example:* `"destroy all creatures"` $\rightarrow$ `TOKEN_BOARD_WIPE_CREATURE`
- **Tagging:** Extract subtypes (Tribes) from the `types` field (e.g., "Elf", "Zombie").
## 4. Phase 2: The Synergy Engine (Logic)
The engine must evaluate every card pair and assign a weighted connection based on three tiers of synergy.
### Tier A: Hard Synergies (Weight: 1.0)
**Logic:** Direct mechanical triggers.
- **Tribal Link:** If `Card_A.tags` (Tribe) $\cap$ `Card_B.text` (contains Tribe name) $\neq \emptyset$.
- **Trigger-Response:** Identify "Providers" (e.g., "Whenever you gain life") and "Payoffs" (e.g., "When you gain life, [Effect]"). Link Provider $\rightarrow$ Payoff.
### Tier B: Functional Similarity (Weight: 0.6)
**Logic:** Substitution/Role mapping.
- **Role Dictionary:** Define roles (e.g., `RAMP`, `CARD_DRAW`, `REMOVAL`).
- **Mapping:** If both cards share the same `Role_ID` based on their Functional Tokens, create a link.
### Tier C: Strategic Archetypes (Weight: 0.3)
**Logic:** Thematic co-occurrence.
- **Archetype Buckets:** Define keyword groups (e.g., `GRAVEYARD_STRAT` = ["mill", "graveyard", "reanimate"]).
- **Density Check:** If both cards have a high overlap of keywords from the same bucket, create a link.
## 5. Phase 3: Database Schema (PSQL)
Implement the following schema:
### Table: `cards`
- `card_id`: UUID (Primary Key)
- `name`: VARCHAR
- `oracle_text`: TEXT
- `mana_cost`: VARCHAR
- `color_identity`: ARRAY[VARCHAR]
- `tags`: ARRAY[VARCHAR] (Stored functional tokens and tribes)
### Table: `synergy_types`
- `type_id`: INT (Primary Key)
- `label`: VARCHAR (e.g., 'Tribal', 'Mechanical', 'Substitute')
### Table: `card_connections`
- `card_id_a`: UUID (FK $\rightarrow$ cards)
- `card_id_b`: UUID (FK $\rightarrow$ cards)
- `type_id`: INT (FK $\rightarrow$ synergy_types)
- `weight`: FLOAT
- **Constraint:** `CHECK (card_id_a < card_id_b)` to prevent bidirectional duplicates.
## 6. Phase 4: Execution Pipeline
The script must execute in the following order:
1. **Ingest:** Parse JSON $\rightarrow$ Bulk load into `cards` table.
2. **Analyze:** Run `TextProcessor` $\rightarrow$ Update `cards.tags`.
3. **Map:**
- Iterate through card pairs.
- Evaluate Tiers A, B, and C.
- Insert identified synergies into `card_connections`.
4. **Index:** Create B-Tree indices on `card_id_a` and `card_id_b`.
## 7. Phase 5: Recommendation Logic (API Level)
The resulting database must support the following query logic for the API:
1. **Input:** A list of `card_ids` currently in a deck.
2. **Query:** Find all `card_id_b` linked to any of the input IDs in `card_connections`.
3. **Aggregate:** Sum the `weight` for each suggested card.
4. **Filter:** Remove suggestions that do not match the `color_identity` of the deck.
5. **Output:** Return the top $N$ cards sorted by aggregate weight.
-32
View File
@@ -1,32 +0,0 @@
# MTG Online Backend - State Management
## Overview
This project manages state for the MTG Online Backend application.
State is persisted in `state.json` and updated after every response.
## State Schema
```json
{
"task_description": "Brief description of the current task",
"current_step": "What step we're on (e.g., 'Fixing backend issues')",
"files_created": ["list of files created"],
"files_modified": ["list of files modified"],
"decisions": ["list of key decisions made"],
"next_steps": ["list of next steps"],
"blockers": null | "description of blocker",
"commit_hash": null | "last commit hash",
"timestamp": "ISO 8601 timestamp"
}
```
## Commands
- **Default**: On new chat, show summary, ask to resume or start fresh
- **Reset**: Clear all state and start fresh
## Current State
- **Last updated**: 2026-05-27T04:00:00Z
- **Status**: Active development
- **Next action**: Await user instructions
-101
View File
@@ -1,101 +0,0 @@
# MTGJSON Data Manager - File Type Support
## Supported File Types
### 1. AllPrintings (Primary Card Database)
- **Accepts:** `AllPrintings.json` OR `AllPrintings.psql`
- **Processing:**
- `.json`: Parses JSON structure, extracts card data from `cards` array
- `.psql`: Parses SQL INSERT statements to extract card data
- **Database:** `mtg_cards` table
- **Required:** Yes (one of the two formats)
### 2. AllIdentifiers (Stable Card Referencing)
- **Accepts:** `AllIdentifiers.json`
- **Processing:** Parses JSON structure, extracts identifiers
- **Database:** `mtg_identifiers` table
- **Required:** Yes
### 3. Keywords & CardTypes (Game Logic/Mechanics)
- **Accepts:** `Keywords.json` and `CardTypes.json`
- **Processing:** Parses JSON arrays
- **Database:** `mtg_keywords` and `mtg_card_types` tables
- **Required:** Yes (both)
### 4. AllDeckFiles (Deck Format Testing)
- **Accepts:** `AllDeckFiles.zip`
- **Processing:**
1. Unzips the archive
2. Finds all `.json` files recursively
3. Parses each JSON file
4. Upserts deck data
- **Database:** `mtg_deck_list` table
- **Required:** Yes
## File Validation
The health check endpoint now validates that all required files are present:
```json
{
"status": "healthy",
"data": {
"required_files": {
"all_present": true,
"found": ["AllPrintings.json", "AllIdentifiers.json", "Keywords.json", "CardTypes.json", "AllDeckFiles.zip"],
"missing": [],
"details": {
"AllPrintings": "OK (.json)",
"AllIdentifiers": "OK",
"Keywords": "OK",
"CardTypes": "OK",
"AllDeckFiles": "OK"
}
}
}
}
```
## PSQL File Parsing
For `AllPrintings.psql`, the manager:
1. Reads the SQL file
2. Extracts `INSERT INTO mtg_cards (...) VALUES (...)` statements using regex
3. Parses column names and values
4. Handles NULL values, quoted strings, and JSON arrays
5. Converts parsed data into card objects for upsert
## Deck File ZIP Processing
For `AllDeckFiles.zip`:
1. Extracts to temporary directory
2. Recursively finds all `.json` files
3. Parses each file expecting deck format:
```json
{
"listId": "...",
"name": "...",
"year": "...",
"date": "...",
"format": "..."
}
```
4. Upserts each deck into the database
5. Cleans up temporary files
## Error Handling
- Validates file existence before processing
- Logs warnings for missing files
- Handles malformed JSON/PSQL gracefully
- Rolls back transactions on individual card failures
- Continues processing remaining files on errors
## Health Check
The app is considered "healthy" when:
1. Cards and sets are loaded in database (>0)
2. All required files are present in the mounted volume
3. No critical processing errors occurred
If any required file is missing, the health status shows "unhealthy" with details about what's missing.
@@ -1,959 +0,0 @@
"""
MTG Card Interaction Rule Engine
Extracts card interactions using structured rules instead of NLP.
Designed for rolling updates when new MTGJSON data is loaded.
"""
import re
from typing import Dict, List, Tuple, Optional, Any
from dataclasses import dataclass
from enum import Enum
class InteractionType(Enum):
"""Types of card interactions."""
MECHANIC = "mechanic"
ARCHETYPE = "archetype"
SYNERGY = "synergy"
COUNTER = "counter"
EVOLUTION = "evolution"
MANA = "mana"
SET_THEME = "set_theme"
class SynergyType(Enum):
"""Types of synergies between cards."""
ARCHETYPE_SUPPORT = "archetype_support"
MECHANIC_SUPPORT = "mechanic_support"
MANA_BASE = "mana_base"
COMBO_PARTNER = "combo_partner"
COUNTER_PARTNER = "counter_partner"
EVOLUTION_CHAIN = "evolution_chain"
class CounterType(Enum):
"""Types of counter relationships."""
DIRECT_COUNTER = "direct_counter"
MANA_DISADVANTAGE = "mana_disadvantage"
OUTCLASS = "outclass"
COUNTER_ROLE = "counter_role"
class EvolutionType(Enum):
"""Types of evolution relationships."""
TRANSFORM = "transform"
EVOLVE = "evolve"
DOUBLE_SIDED = "double_sided"
MODAL_DFC = "modal_dfc"
REPRINTED = "reprinted"
@dataclass
class CardProfile:
"""Structured profile of a card for interaction extraction."""
name: str
mana_cost: Optional[str]
type_line: Optional[str]
oracle_text: Optional[str]
subtypes: Optional[str]
supertypes: Optional[str]
colors: Optional[str]
color_identity: Optional[str]
power: Optional[str]
toughness: Optional[str]
loyalty: Optional[str]
set_code: Optional[str]
set_id: int
card_id: int
# Extracted fields
mechanics: List[str] = None
archetypes: List[str] = None
targets: List[str] = None
triggers: List[str] = None
effects: List[str] = None
themes: List[str] = None
def __post_init__(self):
if self.mechanics is None:
self.mechanics = []
if self.archetypes is None:
self.archetypes = []
if self.targets is None:
self.targets = []
if self.triggers is None:
self.triggers = []
if self.effects is None:
self.effects = []
if self.themes is None:
self.themes = []
class MTGRuleEngine:
"""
Extracts card interactions using structured rules.
This is NOT NLP. It uses:
- Regex patterns for known game language
- Curated dictionaries for mechanics/archetypes
- Game rule logic for determining interactions
"""
def __init__(self):
# Define mechanics and their extraction patterns
self.mechanics_patterns = {
'flying': r'Flying',
'first_strike': r'First strike',
'double_strike': r'Double strike',
'deathtouch': r'Death touch',
'lifelink': r'Lifelink',
'haste': r'Haste',
'trample': r'Trample',
'menace': r'Menace',
'vigilance': r'Vegilance',
'reach': r'Reach',
'indestructible': r'Indestructible',
'hexproof': r'Hexproof',
'shroud': r'Shroud',
'defender': r'Defender',
'landfall': r'Landfall',
'delve': r'Delve',
'soulshift': r'Soulshift',
'suspend': r'Suspend',
'convoke': r'Convoke',
'rampage': r'Rampage',
'toxic': r'Toxic',
'crew': r'Crew',
'equip': r'Equip',
'annihilator': r'Annihilator',
'spectacle': r'Spectacle',
'prowess': r'Prowess',
'aftermath': r'Aftermath',
'adapt': r'Adapt',
'amplify': r'Amplify',
'awaken': r'Awaken',
'banding': r'Band with',
'bestow': r'Bestow',
'burst': r'Burst',
'channel': r'Channel',
'clash': r'Clash',
'curse': r'Curse',
'day_night': r'Day|Night',
'decay': r'Decay',
'defiant': r'Defiant',
'demolish': r'Demolish',
'detain': r'Detain',
'detect': r'Detect',
'devour': r'Devour',
'disguise': r'Disguise',
'disturb': r'Disturb',
'dome': r'Dome',
'dredge': r'Dredge',
'emerge': r'Emerge',
'encore': r'Encore',
'endure': r'Endure',
'evoke': r'Evoke',
'evolve': r'Evolve',
'exalted': r'Exalted',
'exile': r'Exile',
'exploit': r'Exploit',
'extort': r'Extort',
'fairy': r'Fairy',
'fanatic': r'Fanatic',
'fathom': r'Fathom',
'fear': r'Fear',
'feline': r'Feline',
'flash': r'Flash',
'flight': r'Flight',
'foretell': r'Foretell',
'frenzy': r'Frenzy',
'fumble': r'Fumble',
'galvanize': r'Galvanize',
'gateway': r'Gateway',
'genesis': r'Genesis',
'graft': r'Graft',
'grave': r'Grave',
'grit': r'Grit',
'guardian': r'Guardian',
'harvest': r'Harvest',
'healer': r'Healer',
'heroic': r'Heroic',
'hideaway': r'Hideaway',
'hinterland': r'Hinterland',
'hoard': r'Hoard',
'hour': r'Hour',
'illusion': r'Illusion',
'immortal': r'Immortal',
'impulse': r'Impulse',
'inspiration': r'Inspiration',
'instill': r'Instill',
'iron': r'Iron',
'junk': r'Junk',
'kicker': r'Kicker',
'knight': r'Knight',
'land': r'Land',
'leech': r'Leech',
'lich': r'Lich',
'lifespan': r'Lifespan',
'lightning': r'Lightning',
'living': r'Living',
'lurk': r'Lurk',
'madness': r'Madness',
'manifest': r'Manifest',
'map': r'Map',
'meld': r'Meld',
'miracle': r'Miracle',
'mitosis': r'Mitosis',
'modular': r'Modular',
'moon': r'Moon',
'mother': r'Mother',
'morph': r'Morph',
'mutate': r'Mutate',
'ninja': r'Ninja',
'night': r'Night',
'nightmare': r'Nightmare',
'pact': r'Pact',
'paradox': r'Paradox',
'persist': r'Persist',
'pillage': r'Pillage',
'pivot': r'Pivot',
'planar': r'Planar',
'polar': r'Polar',
'pour': r'Pour',
'prey': r'Prey',
'priest': r'Priest',
'primer': r'Primer',
'probe': r'Probe',
'prosperity': r'Prosperity',
'psychic': r'Psychic',
'puppet': r'Puppet',
'quest': r'Quest',
'quote': r'Quote',
'rage': r'Rage',
'raid': r'Raid',
'raise': r'Raise',
'rally': r'Rally',
'rapid': r'Rapid',
'rat': r'Rat',
'rebound': r'Rebound',
'reckless': r'Reckless',
'recoup': r'Recoup',
'reflect': r'Reflect',
'refresh': r'Refresh',
'replicate': r'Replicate',
'reverberate': r'Reverberate',
'reviviant': r'Reviviant',
'rift': r'Rift',
'rip': r'Rip',
'ritual': r'Ritual',
'rite': r'Rite',
'rogue': r'Rogue',
'savant': r'Savant',
'scavenge': r'Scavenge',
'seek': r'Seek',
'shadow': r'Shadow',
'shards': r'Shards',
'skulk': r'Skulk',
'smelt': r'Smelt',
'snap': r'Snap',
'snow': r'Snow',
'spectacle': r'Spectacle',
'splice': r'Splice',
'spore': r'Spore',
'sprawl': r'Sprawl',
'stabilize': r'Stabilize',
'stasis': r'Stasis',
'storm': r'Storm',
'story': r'Story',
'substitute': r'Substitute',
'sunder': r'Sunder',
'surge': r'Surge',
'survive': r'Survive',
'swarm': r'Swarm',
'symbiosis': r'Symbiosis',
'synchronized': r'Synchronized',
'synth': r'Synth',
'table': r'Table',
'taint': r'Taint',
'tank': r'Tank',
'thorn': r'Thorn',
'thwart': r'Thwart',
'time': r'Time',
'tinker': r'Tinker',
'toxin': r'Toxin',
'trail': r'Trail',
'transfigure': r'Transfigure',
'transform': r'Transform',
'transport': r'Transport',
'trouble': r'Trouble',
'tunnel': r'Tunnel',
'unearth': r'Unearth',
'unleash': r'Unleash',
'unmask': r'Unmask',
'unstoppable': r'Unstoppable',
'urborg': r'Urborg',
'urgent': r'Urgent',
'utility': r'Utility',
'vengeful': r'Vengeful',
'vanish': r'Vanish',
'venom': r'Venom',
'victory': r'Victory',
'villainous': r'Villainous',
'vitalize': r'Vitalize',
'void': r'Void',
'voyage': r'Veoyage',
'ward': r'Ward',
'watch': r'Watch',
'weave': r'Weave',
'wed': r'Wed',
'whammy': r'Whammy',
'wild': r'Wild',
'will': r'Will',
'wisp': r'Wisp',
'witch': r'Witch',
'woe': r'Woe',
'wounded': r'Wounded',
'wrap': r'Wrap',
'wrought': r'Wrought',
'wurm': r'Wurm',
'wythe': r'Wythe',
}
# Define archetype patterns
self.archetype_patterns = {
'goblin': r'Goblin',
'elf': r'Elf',
'vampire': r'Veampire',
'angel': r'Angel',
'dragon': r'Dragon',
'human': r'Human',
'zombie': r'Zombie',
'soldier': r'Soldier',
'knight': r'Knight',
'wizard': r'Wizard',
'spirit': r'Spirit',
'demon': r'Demon',
'snake': r'Snake',
'cat': r'Cat',
'wolf': r'Wolf',
'bear': r'Bear',
'bird': r'Bird',
'insect': r'Insect',
'horror': r'Horror',
'goat': r'Goat',
'ox': r'Ox',
'elephant': r'Elephant',
'whale': r'Whale',
'shark': r'Shark',
'fish': r'Fish',
'serpent': r'Serpent',
'lizard': r'Lizard',
'scorpion': r'Scorpion',
'spider': r'Spider',
'rat': r'Rat',
'drake': r'Drake',
'wyvern': r'Wyvern',
'phoenix': r'Phoenix',
'lynx': r'Lynx',
'jaguar': r'Jaguar',
'hydra': r'Hydra',
'leviathan': r'Leviathan',
'kraken': r'Kraken',
'cyclops': r'Cyclops',
'golem': r'Golem',
'homunculus': r'Homunculus',
'clay': r'Clay',
'construct': r'Construct',
'myr': r'Myr',
'aether': r'Aether',
'pumpkin': r'Pumpkin',
'pirate': r'Pirate',
'pegasus': r'Pegasus',
'unicorn': r'Unicorn',
'centaur': r'Centaur',
'merfolk': r'Merfolk',
'mermaid': r'Mermaid',
'naga': r'Naga',
'satyr': r'Satyr',
'dryad': r'Dryad',
'treant': r'Treant',
'elemental': r'Elemental',
'fiend': r'Fiend',
'imp': r'Imp',
'faerie': r'Faerie',
'minion': r'Minion',
'abomination': r'Abomination',
'beast': r'Beast',
'demigod': r'Demigod',
'god': r'God',
'avatar': r'Avatar',
'guardian': r'Guardian',
'warrior': r'Warrior',
'rogue': r'Rogue',
'artificer': r'Artificer',
'bard': r'Bard',
'monk': r'Monk',
'ninja': r'Ninja',
'samurai': r'Samurai',
'assassin': r'Assassin',
'thief': r'Thief',
'acrobat': r'Acrobat',
'explorer': r'Explorer',
'farmer': r'Farmer',
'myth': r'Myth',
'illusion': r'Illusion',
'mirror': r'Mirror',
'phantom': r'Phantom',
'shapeshifter': r'Shapeshifter',
'shaman': r'Shaman',
'skeleton': r'Skeleton',
'slime': r'Slime',
'squirrel': r'Squirrel',
'troll': r'Troll',
'tyrannosaur': r'Tyrannosaur',
'wraith': r'Wraith',
'wurm': r'Wurm',
}
# Target types for counter interactions
self.target_types = {
'creature': r'creature',
'artifact': r'artifact',
'enchantment': r'enchantment',
'instant': r'instant',
'sorcery': r'sorcery',
'planeswalker': r'planeswalker',
'land': r'land',
'player': r'player',
}
# Trigger patterns
self.trigger_patterns = {
'enters_battlefield': r'when [~|this] enters the battlefield',
'leaves_battlefield': r'when [~|this] leaves the battlefield',
'attacks': r'whenever [~|this] attacks',
'blocks': r'whenever [~|this] blocks',
'dies': r'when [~|this] dies',
'damage': r'deals [0-9]+ damage',
'draws_card': r'draw a card|draw two cards',
'gains_life': r'gain [0-9]+ life',
'creates_token': r'create a token',
'taps': r'tap: add',
'untaps': r'untap: add',
'destroys': r'destroy target',
'exiles': r'exile target',
'counters_spell': r'counter target spell',
}
# Effect patterns
self.effect_patterns = {
'gain_flying': r'gain flying',
'gain_first_strike': r'gain first strike',
'gain_double_strike': r'gain double strike',
'gain_deathtouch': r'gain deathtouch',
'gain_lifelink': r'gain lifelink',
'gain_haste': r'gain haste',
'gain_trample': r'gain trample',
'gain_vigilance': r'gain vigilance',
'gain_indestructible': r'gain indestructible',
'gain_hexproof': r'gain hexproof',
'until_end_of_turn': r'until end of turn',
'until_next_turn': r'until your next turn',
}
def extract_mechanics(self, card: CardProfile) -> List[str]:
"""Extract mechanics from card type line and oracle text."""
mechanics = []
# Check type line for mechanics
if card.type_line:
for mechanic, pattern in self.mechanics_patterns.items():
if re.search(pattern, card.type_line, re.IGNORECASE):
mechanics.append(mechanic)
# Check oracle text for mechanics
if card.oracle_text:
for mechanic, pattern in self.mechanics_patterns.items():
if re.search(pattern, card.oracle_text, re.IGNORECASE):
if mechanic not in mechanics:
mechanics.append(mechanic)
return mechanics
def extract_archetypes(self, card: CardProfile) -> List[str]:
"""Extract archetypes from card subtypes."""
archetypes = []
if card.subtypes:
for archetype, pattern in self.archetype_patterns.items():
if re.search(pattern, card.subtypes, re.IGNORECASE):
archetypes.append(archetype)
return archetypes
def extract_targets(self, card: CardProfile) -> List[str]:
"""Extract target types from oracle text."""
targets = []
if card.oracle_text:
for target, pattern in self.target_types.items():
if re.search(pattern, card.oracle_text, re.IGNORECASE):
targets.append(target)
return targets
def extract_triggers(self, card: CardProfile) -> List[str]:
"""Extract trigger conditions from oracle text."""
triggers = []
if card.oracle_text:
for trigger, pattern in self.trigger_patterns.items():
if re.search(pattern, card.oracle_text, re.IGNORECASE):
triggers.append(trigger)
return triggers
def extract_effects(self, card: CardProfile) -> List[str]:
"""Extract game effects from oracle text."""
effects = []
if card.oracle_text:
for effect, pattern in self.effect_patterns.items():
if re.search(pattern, card.oracle_text, re.IGNORECASE):
effects.append(effect)
return effects
def extract_themes(self, card: CardProfile) -> List[str]:
"""Extract set themes based on card characteristics."""
themes = []
# Storm theme
if 'storm' in card.mechanics or 'storm' in card.oracle_text.lower():
themes.append('storm')
# Token theme
if any(e in card.effects for e in ['creates_token']):
themes.append('tokens')
# Mill theme
if any(t in card.triggers for t in ['draws_card']):
themes.append('draw')
# Life gain theme
if any(e in card.effects for e in ['gains_life']):
themes.append('life_gain')
# Board wipe theme
if any(t in card.triggers for t in ['dies']):
themes.append('board_wipe')
# Reanimate theme
if any(t in card.triggers for t in ['leaves_battlefield']):
themes.append('reanimate')
# Countermagic theme
if any(e in card.effects for e in ['counters_spell']):
themes.append('countermagic')
# Card advantage theme
if any(t in card.triggers for t in ['draws_card']):
themes.append('card_advantage')
# Mana acceleration theme
if any(t in card.triggers for t in ['taps', 'untaps']):
themes.append('mana_acceleration')
# Combat tricks theme
if any(e in card.effects for e in ['gain_flying', 'gain_first_strike',
'gain_double_strike', 'gain_deathtouch',
'gain_lifelink', 'gain_vigilance']):
themes.append('combat_tricks')
# ETB effects theme
if any(t in card.triggers for t in ['enters_battlefield']):
themes.append('etb_effects')
# LTB effects theme
if any(t in card.triggers for t in ['leaves_battlefield']):
themes.append('ltb_effects')
return themes
def profile_card(self, card_data: Dict[str, Any]) -> CardProfile:
"""Convert raw MTGJSON card data to CardProfile."""
# Parse subtypes
subtypes = None
if card_data.get('subtypes'):
subtypes = ', '.join(card_data['subtypes'])
# Parse supertypes
supertypes = None
if card_data.get('supertypes'):
supertypes = ', '.join(card_data['supertypes'])
# Parse colors
colors = None
if card_data.get('colors'):
colors = ', '.join(card_data['colors'])
# Parse color identity
color_identity = None
if card_data.get('colorIdentity'):
color_identity = ', '.join(card_data['colorIdentity'])
# Extract interactions
profile = CardProfile(
name=card_data.get('name', ''),
mana_cost=card_data.get('manaCost'),
type_line=card_data.get('typeLine'),
oracle_text=card_data.get('oracleText'),
subtypes=subtypes,
supertypes=supertypes,
colors=colors,
color_identity=color_identity,
power=card_data.get('power'),
toughness=card_data.get('toughness'),
loyalty=card_data.get('loyalty'),
set_code=card_data.get('set', {}).get('code') if card_data.get('set') else None,
set_id=card_data.get('setId', 0),
card_id=card_data.get('id', 0),
)
# Extract mechanics, archetypes, etc.
profile.mechanics = self.extract_mechanics(profile)
profile.archetypes = self.extract_archetypes(profile)
profile.targets = self.extract_targets(profile)
profile.triggers = self.extract_triggers(profile)
profile.effects = self.extract_effects(profile)
profile.themes = self.extract_themes(profile)
return profile
def find_synergies(self, card_a: CardProfile, card_b: CardProfile) -> List[Tuple[str, int, str]]:
"""
Find synergies between two cards.
Returns list of (synergy_type, strength, notes) tuples.
"""
synergies = []
# Same archetype synergy
if card_a.archetypes and card_b.archetypes:
common_archetypes = set(card_a.archetypes) & set(card_b.archetypes)
if common_archetypes:
synergies.append((
'archetype_support',
3,
f"Both are {', '.join(common_archetypes)}"
))
# Mechanic support
if card_a.mechanics and card_b.mechanics:
# If card_b has a mechanic that supports card_a's archetype
for mech in card_a.mechanics:
if mech in card_b.mechanics:
synergies.append((
'mechanic_support',
2,
f"Both have {mech}"
))
# Mana base synergy
if card_a.colors and card_b.colors:
# Check for color compatibility
colors_a = set(card_a.colors.split(','))
colors_b = set(card_b.colors.split(','))
if colors_a == colors_b:
synergies.append((
'mana_base',
4,
"Same color identity"
))
# Combo partner
if card_a.targets and card_b.triggers:
# If card_a targets creatures and card_b triggers on creatures
if 'creature' in card_a.targets and any(t in card_b.triggers for t in ['enters_battlefield', 'dies']):
synergies.append((
'combo_partner',
3,
"Card A targets creatures, Card B interacts with creature entry/death"
))
# Counter partner
if card_a.targets and card_b.targets:
# If they target different types, they complement each other
targets_a = set(card_a.targets)
targets_b = set(card_b.targets)
if targets_a != targets_b and targets_a & targets_b:
synergies.append((
'counter_partner',
2,
"Different target types provide coverage"
))
# Evolution chain
if card_a.name == card_b.name:
synergies.append((
'evolution_chain',
2,
"Same card name (reprint or different version)"
))
return synergies
def find_counters(self, card_a: CardProfile, card_b: CardProfile) -> List[Tuple[str, int, str]]:
"""
Find counter relationships between two cards.
Returns list of (counter_type, strength, notes) tuples.
"""
counters = []
# Different color identities
if card_a.color_identity and card_b.color_identity:
colors_a = set(card_a.color_identity.split(','))
colors_b = set(card_b.color_identity.split(','))
if colors_a != colors_b:
counters.append((
'mana_disadvantage',
2,
"Different color identities create strategic tension"
))
# Outclass
if card_a.power and card_b.power:
try:
power_a = int(card_a.power)
power_b = int(card_b.power)
if power_a > power_b + 1:
counters.append((
'outclass',
3,
f"Card A has higher power ({power_a} vs {power_b})"
))
elif power_b > power_a + 1:
counters.append((
'outclass',
3,
f"Card B has higher power ({power_b} vs {power_a})"
))
except (ValueError, TypeError):
pass
# Counter role
if card_a.targets and 'creature' in card_a.targets:
if card_b.mechanics and any(m in card_b.mechanics for m in ['deathtouch', 'trample']):
counters.append((
'counter_role',
2,
"Card A targets creatures, Card B has combat keywords"
))
return counters
def find_evolution(self, card: CardProfile, all_cards: Dict[int, CardProfile]) -> List[Tuple[str, int, str]]:
"""
Find evolution relationships for a card.
Returns list of (evolution_type, strength, notes) tuples.
"""
evolutions = []
# Find reprints
for other_id, other_card in all_cards.items():
if other_id != card.card_id and card.name == other_card.name:
evolutions.append((
'reprinted',
2,
f"Reprint in {other_card.set_code} (set_id: {other_card.set_id})"
))
# Find transform pairs (same name, different face)
# This would require checking card_faces in the database
return evolutions
def build_interaction_graph(self, cards: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Build interaction graph for a batch of cards.
Returns dictionary with:
- mechanics: card_id -> mechanics list
- archetypes: card_id -> archetypes list
- synergies: (card_a, card_b) -> list of synergies
- counters: (card_a, card_b) -> list of counters
- evolutions: card_id -> list of evolutions
"""
# Profile all cards
profiles = {}
for card_data in cards:
if card_data.get('id'):
profile = self.profile_card(card_data)
profiles[profile.card_id] = profile
# Extract interactions
graph = {
'mechanics': {},
'archetypes': {},
'synergies': [],
'counters': [],
'evolutions': [],
}
# Extract mechanics and archetypes
for card_id, profile in profiles.items():
graph['mechanics'][card_id] = profile.mechanics
graph['archetypes'][card_id] = profile.archetypes
# Find synergies between all card pairs
card_ids = list(profiles.keys())
for i in range(len(card_ids)):
for j in range(i + 1, len(card_ids)):
card_a = profiles[card_ids[i]]
card_b = profiles[card_ids[j]]
synergies = self.find_synergies(card_a, card_b)
if synergies:
graph['synergies'].append({
'card_a': card_a.card_id,
'card_b': card_b.card_id,
'synergies': synergies,
})
# Find counters between all card pairs
for i in range(len(card_ids)):
for j in range(i + 1, len(card_ids)):
card_a = profiles[card_ids[i]]
card_b = profiles[card_ids[j]]
counters = self.find_counters(card_a, card_b)
if counters:
graph['counters'].append({
'card_a': card_a.card_id,
'card_b': card_b.card_id,
'counters': counters,
})
# Find evolutions for each card
for card_id, profile in profiles.items():
evolutions = self.find_evolution(profile, profiles)
if evolutions:
graph['evolutions'].append({
'card_id': card_id,
'evolutions': evolutions,
})
return graph
def main():
"""Test the rule engine with sample data."""
engine = MTGRuleEngine()
# Sample card data
sample_cards = [
{
'id': 1,
'name': 'Lightning Bolt',
'manaCost': '{R}',
'typeLine': 'Instant',
'oracleText': 'Lightning Bolt deals 3 damage to any target.',
'subtypes': [],
'supertypes': [],
'colors': ['R'],
'colorIdentity': ['R'],
'set': {'code': '2X2'},
'setId': 100,
},
{
'id': 2,
'name': 'Lightning Greaves',
'manaCost': '{1}{R}',
'typeLine': 'Artifact — Equipment',
'oracleText': 'Enchanted creature has hexproof and haste.\nEquip {1}',
'subtypes': ['Equipment'],
'supertypes': [],
'colors': ['R'],
'colorIdentity': ['R'],
'power': None,
'toughness': None,
'set': {'code': '10E'},
'setId': 200,
},
{
'id': 3,
'name': 'Elvish Archers',
'manaCost': '{G}',
'typeLine': 'Creature — Elf Ranger',
'oracleText': 'Elvish Archers can\'t be blocked by creatures with power 2 or less.\n{T}: Target creature gets -1/-1 until end of turn.',
'subtypes': ['Elf', 'Ranger'],
'supertypes': [],
'colors': ['G'],
'colorIdentity': ['G'],
'power': '1',
'toughness': '1',
'set': {'code': '5DN'},
'setId': 300,
},
{
'id': 4,
'name': 'Swords to Plowshares',
'manaCost': '{W}',
'typeLine': 'Enchantment',
'oracleText': 'Exile target creature. Its controller gains 1 life.',
'subtypes': [],
'supertypes': [],
'colors': ['W'],
'colorIdentity': ['W'],
'set': {'code': '2X2'},
'setId': 100,
},
]
# Build interaction graph
graph = engine.build_interaction_graph(sample_cards)
# Print results
print("=" * 60)
print("MTG Card Interaction Graph")
print("=" * 60)
print("\n📊 Mechanics:")
for card_id, mechanics in graph['mechanics'].items():
print(f" Card {card_id}: {mechanics}")
print("\n📊 Archetypes:")
for card_id, archetypes in graph['archetypes'].items():
print(f" Card {card_id}: {archetypes}")
print("\n🔗 Synergies:")
for synergy in graph['synergies']:
print(f" Cards {synergy['card_a']}{synergy['card_b']}:")
for syn_type, strength, notes in synergy['synergies']:
print(f" - {syn_type} (strength: {strength}): {notes}")
print("\n⚔️ Counters:")
for counter in graph['counters']:
print(f" Cards {counter['card_a']}{counter['card_b']}:")
for counter_type, strength, notes in counter['counters']:
print(f" - {counter_type} (strength: {strength}): {notes}")
print("\n🔄 Evolutions:")
for evolution in graph['evolutions']:
print(f" Card {evolution['card_id']}:")
for evol_type, strength, notes in evolution['evolutions']:
print(f" - {evol_type} (strength: {strength}): {notes}")
print("\n" + "=" * 60)
print("✅ Interaction graph built successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
-671
View File
@@ -1,671 +0,0 @@
"""
MTG Card Profile Extractor
Extracts structured profiles from MTGJSON card data.
Identifies mechanics, archetypes, mana costs, targets, and other game-relevant attributes.
"""
import re
from typing import List, Dict, Optional, Set
from dataclasses import dataclass
@dataclass
class CardProfile:
"""
Structured profile of a card for interaction analysis.
Contains all relevant game attributes extracted from MTGJSON data.
"""
# Basic info
id: int
name: str
mana_cost: Optional[str]
type_line: Optional[str]
oracle_text: Optional[str]
subtypes: Optional[str]
supertypes: Optional[str]
set_code: Optional[str]
# Extracted attributes
colors: List[str] = None # ['W', 'U', 'B', 'R', 'G']
color_identity: List[str] = None
mechanics: List[str] = None
archetypes: List[str] = None
targets: List[str] = None # ['creature', 'artifact', 'player', etc.]
triggers: List[str] = None
effects: List[str] = None
themes: List[str] = None # ['storm', 'tokens', 'draw', etc.]
def __post_init__(self):
"""Initialize lists if None."""
if self.colors is None:
self.colors = []
if self.color_identity is None:
self.color_identity = []
if self.mechanics is None:
self.mechanics = []
if self.archetypes is None:
self.archetypes = []
if self.targets is None:
self.targets = []
if self.triggers is None:
self.triggers = []
if self.effects is None:
self.effects = []
if self.themes is None:
self.themes = []
def to_dict(self) -> Dict:
"""Convert profile to dictionary."""
return {
'id': self.id,
'name': self.name,
'mana_cost': self.mana_cost,
'type_line': self.type_line,
'oracle_text': self.oracle_text,
'subtypes': self.subtypes,
'supertypes': self.supertypes,
'set_code': self.set_code,
'colors': self.colors,
'color_identity': self.color_identity,
'mechanics': self.mechanics,
'archetypes': self.archetypes,
'targets': self.targets,
'triggers': self.triggers,
'effects': self.effects,
'themes': self.themes,
}
class CardProfileExtractor:
"""
Extracts card profiles from MTGJSON data.
Uses regex patterns and curated dictionaries to identify:
- Mana costs and color identity
- Game mechanics (flying, first strike, etc.)
- Archetypes (goblin, elf, vampire, etc.)
- Targets (creature, artifact, player, etc.)
- Triggers (enters battlefield, dies, attacks, etc.)
- Effects (gain flying, draw card, etc.)
- Themes (storm, tokens, mill, etc.)
"""
# Color symbols in mana costs
COLOR_SYMBOLS = {
'{W}': 'W',
'{U}': 'U',
'{B}': 'B',
'{R}': 'R',
'{G}': 'G',
}
# Known mechanics and their patterns
MECHANICS = {
'flying': r'Flying',
'first_strike': r'First strike',
'double_strike': r'Double strike',
'deathtouch': r'Death touch',
'lifelink': r'Lifelink',
'haste': r'Haste',
'trample': r'Trample',
'menace': r'Menace',
'vigilance': r'Vegilance',
'reach': r'Reach',
'indestructible': r'Indestructible',
'hexproof': r'Hexproof',
'shroud': r'Shroud',
'defender': r'Defender',
'landfall': r'Landfall',
'delve': r'Delve',
'suspend': r'Suspend',
'convoke': r'Convoke',
'rampage': r'Rampage',
'toxic': r'Toxic',
'crew': r'Crew',
'equip': r'Equip',
'annihilator': r'Annihilator',
'spectacle': r'Spectacle',
'prowess': r'Prowess',
'aftermath': r'Aftermath',
'adapt': r'Adapt',
'amplify': r'Amplify',
'awaken': r'Awaken',
'kicker': r'Kicker',
'morph': r'Morph',
'evolve': r'Evolve',
'exalted': r'Exalted',
'storm': r'Storm',
'madness': r'Madness',
'manifest': r'Manifest',
'modular': r'Modular',
'mutate': r'Mutate',
'transform': r'Transform',
'unearth': r'Unearth',
'persist': r'Persist',
'rebound': r'Rebound',
'replicate': r'Replicate',
'soulshift': r'Soulshift',
'dredge': r'Dredge',
'devour': r'Devour',
'banding': r'Band with',
'bestow': r'Bestow',
'channel': r'Channel',
'clash': r'Clash',
'curse': r'Curse',
'dwell': r'Dwell',
'evoke': r'Evoke',
'exploit': r'Exploit',
'extort': r'Extort',
'flash': r'Flash',
'foretell': r'Foretell',
'frenzy': r'Frenzy',
'grudge': r'Grudge',
'heroic': r'Heroic',
'hideaway': r'Hideaway',
'horrify': r'Horrify',
'impetus': r'Impetus',
'infect': r'Infect',
'journey': r'Journey',
'kicker': r'Kicker',
'landfall': r'Landfall',
'meld': r'Meld',
'miracle': r'Miracle',
'monstrosity': r'Monstrosity',
'morph': r'Morph',
'mutate': r'Mutate',
'ninja': r'Ninja',
'pact': r'Pact',
'persist': r'Persist',
'provoke': r'Provoke',
'quest': r'Quest',
'raid': r'Raid',
'rebound': r'Rebound',
'replicate': r'Replicate',
'revolt': r'Revolt',
'shroud': r'Shroud',
'skulk': r'Skulk',
'snow': r'Snow',
'splice': r'Splice',
'staunch': r'Staunch',
'storm': r'Storm',
'suspend': r'Suspend',
'surge': r'Surge',
'swarm': r'Swarm',
'thorn': r'Thorn',
'toxic': r'Toxic',
'transfigure': r'Transfigure',
'transform': r'Transform',
'unearth': r'Unearth',
'unleash': r'Unleash',
'vampiric': r'Vampiric',
'ward': r'Ward',
'willow': r'Willow',
'winter': r'Winter',
'wither': r'Wither',
'wurm': r'Wurm',
}
# Known archetypes and their patterns
ARCHETYPES = {
'goblin': r'Goblin',
'elf': r'Elf',
'vampire': r'Veampire',
'angel': r'Angel',
'dragon': r'Dragon',
'human': r'Human',
'zombie': r'Zombie',
'soldier': r'Soldier',
'knight': r'Knight',
'wizard': r'Wizard',
'spirit': r'Spirit',
'demon': r'Demon',
'snake': r'Snake',
'cat': r'Cat',
'wolf': r'Wolf',
'bear': r'Bear',
'bird': r'Bird',
'insect': r'Insect',
'horror': r'Horror',
'goat': r'Goat',
'ox': r'Ox',
'elephant': r'Elephant',
'whale': r'Whale',
'shark': r'Shark',
'fish': r'Fish',
'serpent': r'Serpent',
'lizard': r'Lizard',
'scorpion': r'Scorpion',
'spider': r'Spider',
'rat': r'Rat',
'drake': r'Drake',
'wyvern': r'Wyvern',
'phoenix': r'Phoenix',
'lynx': r'Lynx',
'jaguar': r'Jaguar',
'hydra': r'Hydra',
'leviathan': r'Leviathan',
'kraken': r'Kraken',
'cyclops': r'Cyclops',
'golem': r'Golem',
'homunculus': r'Homunculus',
'clay': r'Clay',
'construct': r'Construct',
'myr': r'Myr',
'pirate': r'Pirate',
'pegasus': r'Pegasus',
'unicorn': r'Unicorn',
'centaur': r'Centaur',
'merfolk': r'Merfolk',
'mermaid': r'Mermaid',
'naga': r'Naga',
'satyr': r'Satyr',
'dryad': r'Dryad',
'treant': r'Treant',
'elemental': r'Elemental',
'fiend': r'Fiend',
'imp': r'Imp',
'faerie': r'Faerie',
'minion': r'Minion',
'abomination': r'Abomination',
'beast': r'Beast',
'demigod': r'Demigod',
'god': r'God',
'avatar': r'Avatar',
'guardian': r'Guardian',
'warrior': r'Warrior',
'rogue': r'Rogue',
'artificer': r'Artificer',
'bard': r'Bard',
'monk': r'Monk',
'ninja': r'Ninja',
'samurai': r'Samurai',
'assassin': r'Assassin',
'thief': r'Thief',
'acrobat': r'Acrobat',
'explorer': r'Explorer',
'myth': r'Myth',
'illusion': r'Illusion',
'mirror': r'Mirror',
'phantom': r'Phantom',
'shapeshifter': r'Shapeshifter',
'shaman': r'Shaman',
'skeleton': r'Skeleton',
'slime': r'Slime',
'squirrel': r'Squirrel',
'troll': r'Troll',
'tyrannosaur': r'Tyrannosaur',
'wraith': r'Wraith',
'wurm': r'Wurm',
}
# Target types and their patterns
TARGET_TYPES = {
'creature': r'creature',
'artifact': r'artifact',
'enchantment': r'enchantment',
'instant': r'instant',
'sorcery': r'sorcery',
'planeswalker': r'planeswalker',
'land': r'land',
'player': r'player',
'spell': r'spell',
'permanent': r'permanent',
'creature card': r'creature [Cc]ard',
}
# Trigger conditions and their patterns
TRIGGERS = {
'enters_battlefield': r'when [~|this] enters the battlefield',
'leaves_battlefield': r'when [~|this] leaves the battlefield',
'attacks': r'whenever [~|this] attacks',
'blocks': r'whenever [~|this] blocks',
'dies': r'when [~|this] dies',
'damage': r'deals [0-9]+ damage',
'draws_card': r'draw a card|draw two cards',
'gains_life': r'gain [0-9]+ life',
'creates_token': r'create a token',
'taps': r'tap: add',
'untaps': r'untap: add',
'destroys': r'destroy target',
'exiles': r'exile target',
'counters_spell': r'counter target spell',
}
# Game effects and their patterns
EFFECTS = {
'gain_flying': r'gain flying',
'gain_first_strike': r'gain first strike',
'gain_double_strike': r'gain double strike',
'gain_deathtouch': r'gain deathtouch',
'gain_lifelink': r'gain lifelink',
'gain_haste': r'gain haste',
'gain_trample': r'gain trample',
'gain_vigilance': r'gain vigilance',
'gain_indestructible': r'gain indestructible',
'gain_hexproof': r'gain hexproof',
'until_end_of_turn': r'until end of turn',
'until_next_turn': r'until your next turn',
'deal_damage': r'deal [0-9]+ damage',
'gain_life': r'gain [0-9]+ life',
'draw_card': r'draw [0-9]+ card',
'create_token': r'create [0-9]+ token',
'destroy': r'destroy target',
'exile': r'exile target',
'counter_spell': r'counter target spell',
}
# Theme keywords and their patterns
THEMES = {
'storm': r'Storm',
'tokens': r'create a token',
'draw': r'draw a card',
'life_gain': r'gain life',
'board_wipe': r'destroy all',
'reanimate': r'put from grave',
'countermagic': r'counter target spell',
'card_advantage': r'draw',
'mana_acceleration': r'tap: add',
'combat_tricks': r'gain [A-Za-z]+ until end of turn',
'etb_effects': r'enters the battlefield',
'ltb_effects': r'leaves the battlefield',
'mill': r'put on bottom of library',
'draw_go': r'draw a card',
'aggro': r'deal [0-9]+ damage',
'control': r'counter target spell',
'midrange': r'creature',
}
def __init__(self):
"""Initialize the profile extractor."""
pass
def extract_colors(self, mana_cost: Optional[str]) -> List[str]:
"""
Extract colors from mana cost.
Args:
mana_cost: Mana cost string (e.g., '{1}{R}')
Returns:
List of color symbols (e.g., ['R'])
"""
if not mana_cost:
return []
colors = []
for symbol, color in self.COLOR_SYMBOLS.items():
if symbol in mana_cost:
if color not in colors:
colors.append(color)
return colors
def extract_mechanics(self, card_type_line: Optional[str], card_oracle: Optional[str]) -> List[str]:
"""
Extract game mechanics from card text.
Args:
card_type_line: Card type line (e.g., 'Creature - Goblin Warrior')
card_oracle: Card oracle text
Returns:
List of mechanic names (e.g., ['haste', 'trample'])
"""
mechanics = []
# Combine type line and oracle text for checking
text_to_check = f"{card_type_line or ''} {card_oracle or ''}".upper()
for mechanic, pattern in self.MECHANICS.items():
if re.search(pattern, text_to_check, re.IGNORECASE):
if mechanic not in mechanics:
mechanics.append(mechanic)
return mechanics
def extract_archetypes(self, card_subtypes: Optional[str]) -> List[str]:
"""
Extract archetypes from card subtypes.
Args:
card_subtypes: Card subtypes (e.g., 'Goblin, Warrior')
Returns:
List of archetype names (e.g., ['goblin'])
"""
if not card_subtypes:
return []
archetypes = []
for archetype, pattern in self.ARCHETYPES.items():
if re.search(pattern, card_subtypes, re.IGNORECASE):
if archetype not in archetypes:
archetypes.append(archetype)
return archetypes
def extract_targets(self, card_oracle: Optional[str]) -> List[str]:
"""
Extract target types from oracle text.
Args:
card_oracle: Card oracle text
Returns:
List of target types (e.g., ['creature', 'player'])
"""
if not card_oracle:
return []
targets = []
for target, pattern in self.TARGET_TYPES.items():
if re.search(pattern, card_oracle, re.IGNORECASE):
if target not in targets:
targets.append(target)
return targets
def extract_triggers(self, card_oracle: Optional[str]) -> List[str]:
"""
Extract trigger conditions from oracle text.
Args:
card_oracle: Card oracle text
Returns:
List of trigger names (e.g., ['enters_battlefield', 'dies'])
"""
if not card_oracle:
return []
triggers = []
for trigger, pattern in self.TRIGGERS.items():
if re.search(pattern, card_oracle, re.IGNORECASE):
if trigger not in triggers:
triggers.append(trigger)
return triggers
def extract_effects(self, card_oracle: Optional[str]) -> List[str]:
"""
Extract game effects from oracle text.
Args:
card_oracle: Card oracle text
Returns:
List of effect names (e.g., ['gain_flying', 'draw_card'])
"""
if not card_oracle:
return []
effects = []
for effect, pattern in self.EFFECTS.items():
if re.search(pattern, card_oracle, re.IGNORECASE):
if effect not in effects:
effects.append(effect)
return effects
def extract_themes(self, card_mechanics: List[str], card_triggers: List[str],
card_effects: List[str], card_targets: List[str]) -> List[str]:
"""
Extract card themes based on characteristics.
Args:
card_mechanics: List of mechanics
card_triggers: List of triggers
card_effects: List of effects
card_targets: List of targets
Returns:
List of theme names (e.g., ['storm', 'tokens'])
"""
themes = []
# Storm theme
if 'storm' in card_mechanics or 'storm' in card_targets:
themes.append('storm')
# Token theme
if any(e in card_effects for e in ['create_token', 'draw_card']):
themes.append('tokens')
# Mill theme
if any(t in card_triggers for t in ['draws_card']):
themes.append('mill')
# Life gain theme
if any(e in card_effects for e in ['gain_life', 'draw_card']):
themes.append('life_gain')
# Board wipe theme
if any(t in card_triggers for t in ['dies']):
themes.append('board_wipe')
# Reanimate theme
if any(t in card_triggers for t in ['leaves_battlefield']):
themes.append('reanimate')
# Countermagic theme
if any(e in card_effects for e in ['counter_spell']):
themes.append('countermagic')
# Card advantage theme
if any(t in card_triggers for t in ['draws_card']):
themes.append('card_advantage')
# Mana acceleration theme
if any(t in card_triggers for t in ['taps']):
themes.append('mana_acceleration')
# Combat tricks theme
if any(e in card_effects for e in ['gain_flying', 'gain_first_strike',
'gain_double_strike', 'gain_deathtouch',
'gain_lifelink', 'gain_vigilance']):
themes.append('combat_tricks')
# ETB effects theme
if any(t in card_triggers for t in ['enters_battlefield']):
themes.append('etb_effects')
# LTB effects theme
if any(t in card_triggers for t in ['leaves_battlefield']):
themes.append('ltb_effects')
# Aggro theme
if any(e in card_effects for e in ['deal_damage']):
themes.append('aggro')
# Control theme
if any(e in card_effects for e in ['counter_spell']):
themes.append('control')
# Midrange theme
if any(t in card_targets for t in ['creature']):
themes.append('midrange')
return themes
def extract_profile(self, card_data: Dict) -> CardProfile:
"""
Extract a complete card profile from MTGJSON data.
Args:
card_data: MTGJSON card dictionary
Returns:
CardProfile object with all extracted attributes
"""
# Parse subtypes
subtypes = None
if card_data.get('subtypes'):
subtypes = ', '.join(card_data['subtypes'])
# Parse supertypes
supertypes = None
if card_data.get('supertypes'):
supertypes = ', '.join(card_data['supertypes'])
# Extract colors from mana cost
colors = self.extract_colors(card_data.get('manaCost'))
# Extract mechanics
mechanics = self.extract_mechanics(
card_data.get('typeLine'),
card_data.get('oracleText')
)
# Extract archetypes
archetypes = self.extract_archetypes(subtypes)
# Extract targets
targets = self.extract_targets(card_data.get('oracleText'))
# Extract triggers
triggers = self.extract_triggers(card_data.get('oracleText'))
# Extract effects
effects = self.extract_effects(card_data.get('oracleText'))
# Extract themes
themes = self.extract_themes(mechanics, triggers, effects, targets)
# Create profile
profile = CardProfile(
id=card_data.get('id', 0),
name=card_data.get('name', ''),
mana_cost=card_data.get('manaCost'),
type_line=card_data.get('typeLine'),
oracle_text=card_data.get('oracleText'),
subtypes=subtypes,
supertypes=supertypes,
set_code=card_data.get('set', {}).get('code') if card_data.get('set') else None,
colors=colors,
color_identity=card_data.get('colorIdentity'),
mechanics=mechanics,
archetypes=archetypes,
targets=targets,
triggers=triggers,
effects=effects,
themes=themes,
)
return profile
def extract_profiles_batch(self, cards: List[Dict]) -> List[CardProfile]:
"""
Extract profiles for a batch of cards.
Args:
cards: List of MTGJSON card dictionaries
Returns:
List of CardProfile objects
"""
return [self.extract_profile(card) for card in cards]
-145
View File
@@ -1,145 +0,0 @@
#!/usr/bin/env python3
"""Check MTGJSON data status in filesystem and database."""
import asyncio
from pathlib import Path
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
import sys
sys.path.append("/app")
from app.services.mtgjson_manager import MTGJSONManager
from app.config import get_settings
async def check_mtgjson_status():
"""Comprehensive check of MTGJSON data status."""
print("=" * 60)
print("MTGJSON DATA STATUS REPORT")
print("=" * 60)
settings = get_settings()
# 1. Check data directory
print("\n1. DATA DIRECTORY CHECK")
print("-" * 40)
data_dir = Path(settings.DATA_DIR)
print(f"Data Directory: {data_dir}")
print(f"Directory exists: {data_dir.exists()}")
if data_dir.exists():
files = list(data_dir.glob("*.json.gz")) + list(data_dir.glob("*.json"))
print(f"MTGJSON files found: {len(files)}")
# Check specific files
required_files = [
"AllPrintings.json.gz",
"AllSetFiles.json.gz",
"AllIdentifiers.json.gz",
"CardTypes.json.gz",
"Keywords.json.gz",
"MagicRoots.json.gz",
"MagicSets.json.gz",
"SetTranslations.json.gz"
]
missing_files = []
existing_files = []
for f in required_files:
filepath = data_dir / f
if filepath.exists():
size_mb = filepath.stat().st_size / (1024 * 1024)
existing_files.append((f, size_mb))
print(f"{f}: {size_mb:.1f} MB")
else:
missing_files.append(f)
print(f"{f}: MISSING")
print(f"\n Summary: {len(existing_files)}/{len(required_files)} required files present")
if missing_files:
print(f" Missing: {', '.join(missing_files)}")
else:
print(" ERROR: Data directory does not exist!")
# 2. Check database status
print("\n2. DATABASE STATUS CHECK")
print("-" * 40)
db_url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}"
db_url += f"@postgres-mtgdata:5432/{settings.POSTGRES_DB}"
try:
engine = create_async_engine(db_url)
async with AsyncSession(engine) as session:
# Check tables
result = await session.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;
"""))
tables = [row[0] for row in result.fetchall()]
print(f"Tables found: {len(tables)}")
for table in tables:
print(f" - {table}")
# Check key tables
print("\nKey table statistics:")
key_tables = ['mtg_set', 'mtg_card', 'mtg_identifiers', 'mtg_keywords', 'mtg_refresh_log']
for table in key_tables:
if table in tables:
result = await session.execute(text(f"SELECT COUNT(*) FROM {table}"))
count = result.scalar()
print(f" {table}: {count:,} records")
else:
print(f" {table}: TABLE NOT FOUND")
# Check refresh log
if 'mtg_refresh_log' in tables:
result = await session.execute(text("""
SELECT refresh_type, status, created_at, error_message
FROM mtg_refresh_log
ORDER BY created_at DESC
LIMIT 5;
"""))
rows = result.fetchall()
if rows:
print("\nRecent refresh operations:")
for row in rows:
status_icon = "" if row[1] == 'SUCCESS' else ""
print(f" {status_icon} {row[0]}: {row[1]} at {row[2]}")
if row[3]:
print(f" Error: {row[3]}")
else:
print("\nNo refresh operations logged")
except Exception as e:
print(f"ERROR: Could not connect to database: {e}")
return
# 3. Check MTGJSON manager status
print("\n3. MTGJSON MANAGER STATUS")
print("-" * 40)
try:
manager = MTGJSONManager()
status = manager.get_status()
print(f"Status: {status['status']}")
if status.get('data'):
print(f" Sets count: {status['data'].get('sets_count', 0)}")
print(f" Cards count: {status['data'].get('cards_count', 0)}")
print(f" Last refresh: {status['data'].get('last_refresh')}")
except Exception as e:
print(f"ERROR: Could not create MTGJSON manager: {e}")
print("\n" + "=" * 60)
if __name__ == "__main__":
asyncio.run(check_mtgjson_status())
-90
View File
@@ -1,90 +0,0 @@
#!/usr/bin/env python3
"""Check MTGJSON data status in the database and filesystem."""
import asyncio
import os
from pathlib import Path
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
import sys
sys.path.append("/home/wall-o/projects/mtgonline/backend")
from app.services.mtgjson_manager import MTGJSONManager
from app.config import get_settings
async def check_mtgjson_status():
"""Check MTGJSON data download and database status."""
print("=== MTGJSON Data Status Check ===\n")
# Check data directory
settings = get_settings()
data_dir = Path(settings.DATA_DIR)
print(f"Data Directory: {data_dir}")
print(f"Directory exists: {data_dir.exists()}")
if data_dir.exists():
files = list(data_dir.glob("*.json.gz"))
files += list(data_dir.glob("*.json"))
print(f"Found {len(files)} MTGJSON files:")
for f in sorted(files)[:20]: # Show first 20
size_mb = f.stat().st_size / (1024 * 1024)
print(f" - {f.name} ({size_mb:.1f} MB)")
if len(files) > 20:
print(f" ... and {len(files) - 20} more files")
else:
print("WARNING: Data directory does not exist!")
# Check database status
print("\n=== Database Status ===")
try:
# Try to connect to the MTGJSON database
engine = create_async_engine(
f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}"
f"@postgres-mtgdata:5432/{settings.POSTGRES_DB}"
)
async with AsyncSession(engine) as session:
# Check if tables exist
result = await session.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;
"""))
tables = [row[0] for row in result.fetchall()]
print(f"Found {len(tables)} tables in database:")
for table in tables:
print(f" - {table}")
# Check specific MTGJSON tables
mtg_tables = ['mtg_set', 'mtg_card', 'mtg_identifiers', 'mtg_keywords']
if 'mtg_refresh_log' in tables:
result = await session.execute(text("SELECT COUNT(*) FROM mtg_refresh_log"))
count = result.scalar()
print(f"\nRefresh log entries: {count}")
# Check key tables
for table in ['mtg_set', 'mtg_card', 'mtg_identifiers']:
if table in tables:
result = await session.execute(text(f"SELECT COUNT(*) FROM {table}"))
count = result.scalar()
print(f"{table}: {count:,} records")
except Exception as e:
print(f"ERROR connecting to database: {e}")
# Try to create MTGJSON manager and check status
print("\n=== MTGJSON Manager Status ===")
try:
manager = MTGJSONManager()
status = manager.get_status()
print(f"Status: {status}")
except Exception as e:
print(f"ERROR creating MTGJSON manager: {e}")
if __name__ == "__main__":
asyncio.run(check_mtgjson_status())
-213
View File
@@ -1,213 +0,0 @@
# MTG Card Interaction Pipeline - Code Review
## Overview
The interaction pipeline consists of four main modules:
1. `card_profile_extractor.py` - Extracts structured profiles from MTGJSON data
2. `interaction_determinator.py` - Determines interactions between card pairs
3. `interaction_recommender.py` - Generates recommendations based on interactions
4. `interaction_pipeline.py` - Orchestrates the full pipeline
## Issues Found
### 1. Import Inconsistency (Critical)
**File**: `interaction_pipeline.py`
**Issue**: Complex import for `sessionmaker`
```python
self.SessionLocal = __import__('sqlalchemy.orm', fromlist=['sessionmaker']).sessionmaker(bind=self.engine)
```
**Fix**: Use direct import at module level:
```python
from sqlalchemy.orm import sessionmaker
# ...
self.SessionLocal = sessionmaker(bind=self.engine)
```
### 2. Type Hints Inconsistency (Medium)
**File**: `interaction_pipeline.py`
**Issue**: Inconsistent type hints
```python
def extract_profiles(self, cards: List[Dict]) -> List: # Missing type parameter
def determine_interactions(self, profiles) -> dict: # Missing parameter type
```
**Fix**: Add proper type hints:
```python
def extract_profiles(self, cards: List[Dict]) -> List[CardProfile]:
def determine_interactions(self, profiles: List[CardProfile]) -> dict:
```
### 3. Complex Conditional Logic (High)
**File**: `interaction_pipeline.py`
**Issue**: Nested ternary operators for synergy_type and counter_type determination
```python
"synergy_type": "archetype" if interaction.metadata and interaction.metadata.get('common_archetypes') else
"mechanic" if interaction.metadata and interaction.metadata.get('mechanics') else
"mana" if interaction.metadata and interaction.metadata.get('colors') else
"combo" if interaction.metadata and interaction.metadata.get('card_a_targets') else
"support",
```
**Fix**: Extract to helper methods or use lookup dictionaries
### 4. Missing Validation (Medium)
**File**: `interaction_pipeline.py`
**Issue**: No validation for empty card lists or invalid data
**Fix**: Add validation at the start of methods
### 5. Evolution Type Mapping (High)
**File**: `interaction_pipeline.py`
**Issue**: Using `interaction.interaction_type` which is 'evolution' for all evolutions
**Fix**: Map to specific evolution types based on metadata
## Recommended Fixes
### Fix 1: Import Structure
```python
# At top of file
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
# In __init__
self.SessionLocal = sessionmaker(bind=self.engine)
```
### Fix 2: Type Hints
```python
def extract_profiles(self, cards: List[Dict]) -> List[CardProfile]:
return self.profile_extractor.extract_profiles_batch(cards)
def determine_interactions(self, profiles: List[CardProfile]) -> dict:
return self.determinator.determine_all_interactions(profiles)
```
### Fix 3: Helper Methods for Type Determination
```python
def _determine_synergy_type(self, interaction) -> str:
"""Determine synergy type from interaction metadata."""
metadata = interaction.metadata or {}
if 'common_archetypes' in metadata:
return 'archetype'
elif 'mechanics' in metadata:
return 'mechanic'
elif 'colors' in metadata:
return 'mana'
elif 'card_a_targets' in metadata:
return 'combo'
else:
return 'support'
def _determine_counter_type(self, interaction) -> str:
"""Determine counter type from interaction metadata."""
metadata = interaction.metadata or {}
if 'colors_a' in metadata:
return 'color'
elif 'power_a' in metadata:
return 'stats'
else:
return 'keyword'
def _determine_evolution_type(self, interaction) -> str:
"""Determine evolution type from interaction metadata."""
metadata = interaction.metadata or {}
if 'card_name' in metadata:
return 'reprint'
else:
return 'evolution'
```
### Fix 4: Add Validation
```python
def run_initial_load(self, set_code: Optional[str] = None):
"""Run initial load with validation."""
if not set_code:
logger.info("No set code provided, loading all cards")
all_cards = self.load_cards_from_db(set_code)
if not all_cards:
logger.warning("No cards found in database")
return
# Continue with processing...
```
## Accuracy Review
### Profile Extraction
**Correct**: Color extraction from mana cost
**Correct**: Mechanic extraction using regex patterns
**Correct**: Archetype extraction from subtypes
**Correct**: Target extraction from oracle text
**Correct**: Trigger and effect extraction
**Correct**: Theme extraction based on characteristics
### Interaction Determination
**Correct**: Archetype synergy detection
**Correct**: Mana synergy detection
**Correct**: Mechanic synergy detection (haste+trample, lifelink+combat)
**Correct**: Combo synergy detection (targets + triggers)
**Correct**: Support synergy detection
**Correct**: Counter detection (color, stats, keywords)
**Correct**: Evolution detection (reprints)
### Database Schema
**Correct**: Synergies table with proper constraints
**Correct**: Counters table with proper constraints
**Correct**: Evolutions table with proper constraints
**Correct**: Statistics table with proper aggregations
**Correct**: Foreign key relationships
**Correct**: Unique constraints to prevent duplicates
## Consistency Issues
### 1. File Organization
- All files in `/home/wall-o/projects/mtgonline/backend/scripts/`
- No clear separation between core logic and pipeline
- **Recommendation**: Keep as is for simplicity
### 2. Naming Conventions
- **Good**: Consistent use of snake_case for methods
- **Good**: Consistent use of CamelCase for classes
- **Issue**: Mixed use of `set_code` parameter naming
- **Recommendation**: Standardize on `set_code`
### 3. Error Handling
- **Good**: Try/finally blocks for database connections
- **Issue**: No specific exception handling for database errors
- **Recommendation**: Add specific exception types
### 4. Logging
- **Good**: Consistent logging format
- **Good**: Appropriate log levels (INFO, WARNING, ERROR)
- **Issue**: Missing DEBUG logging for development
- **Recommendation**: Add DEBUG level logging
## Summary
### Critical Issues (Must Fix)
1. ❌ Import structure for sessionmaker
2. ❌ Complex conditional logic for type determination
### High Priority Issues (Should Fix)
3. ❌ Missing type hints
4. ❌ Evolution type mapping
5. ❌ Missing validation
### Medium Priority Issues (Nice to Have)
6. ⚠️ Add specific exception handling
7. ⚠️ Add DEBUG logging
8. ⚠️ Standardize parameter naming
### Low Priority Issues (Can Defer)
9. ✅ All core logic is accurate and correct
10. ✅ Database schema is well-designed
11. ✅ Interaction determination logic is sound
## Next Steps
1. **Fix Critical Issues**: Update import structure and simplify conditional logic
2. **Fix High Priority**: Add proper type hints and validation
3. **Test**: Run pipeline with sample data to verify functionality
4. **Document**: Add docstrings and inline comments for complex logic
@@ -1,927 +0,0 @@
"""
MTG Card Interaction Graph Schema
Creates tables for categorizing cards based on their interactions with each other.
This creates a knowledge graph of card relationships including:
- Synergies (cards that work well together)
- Combos (cards that create powerful combinations)
- Counters (cards that counter each other)
- Evolution chains (cards that transform/evolve)
- Partners (commander partnerships, etc.)
- Archetypes (goblins, vampires, elves, etc.)
- Mechanics (first strike, trample, flying, etc.)
- Themes (storm, tokens, mill, etc.)
- Mana relationships (land support)
- Set themes (cards that share set-specific themes)
"""
from sqlalchemy import create_engine, text
DB_URL = "postgresql+psycopg2://mtgonline:mtgonline_pass@172.18.0.2:5432/mtgdata"
class CardInteractionGraph:
"""Creates and manages the card interaction knowledge graph."""
def __init__(self):
self.engine = create_engine(DB_URL)
self.conn = None
def connect(self):
"""Connect to database."""
self.conn = self.engine.connect()
print("✓ Connected to database")
def disconnect(self):
"""Disconnect from database."""
if self.conn:
self.conn.close()
self.engine.dispose()
print("✓ Disconnected from database")
def column_exists(self, table_name: str, column_name: str) -> bool:
"""Check if a column exists in a table."""
result = self.conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = :table AND column_name = :column
"""), {"table": table_name, "column": column_name})
return result.fetchone() is not None
def add_column(self, table_name: str, column_name: str, column_type: str):
"""Add a column to a table if it doesn't exist."""
if not self.column_exists(table_name, column_name):
self.conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}"))
print(f" ✓ Added: {table_name}.{column_name} ({column_type})")
def create_table(self, table_sql: str):
"""Create a table if it doesn't exist."""
self.conn.execute(text(table_sql))
print(f" ✓ Created table: {table_sql.split('CREATE TABLE')[1].split('(')[0].strip()}")
def create_unique_constraint(self, constraint_sql: str):
"""Create a unique constraint if it doesn't exist."""
try:
self.conn.execute(text(constraint_sql))
print(f" ✓ Created constraint: {constraint_sql.split('ADD')[1].split('CONSTRAINT')[1].split('(')[0].strip()}")
except Exception as e:
# Constraint might already exist
pass
def create_index(self, index_sql: str):
"""Create an index if it doesn't exist."""
self.conn.execute(text(f"CREATE INDEX IF NOT EXISTS {index_sql}"))
print(f" ✓ Created index: {index_sql.split('ON ')[1].split(' ')[0]}")
def create_card_mechanics_table(self):
"""Create table for card mechanics (first strike, trample, flying, etc.)."""
print("\n📊 Creating mtg_card_mechanics table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_mechanics (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
mechanic VARCHAR(100) NOT NULL,
strength INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, mechanic)
)
""")
# Add indexes for frequently queried mechanics
indexes = [
"idx_mechanics_card_id ON mtg_card_mechanics(card_id)",
"idx_mechanics_mechanic ON mtg_card_mechanics(mechanic)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card mechanics table created")
def create_card_archetypes_table(self):
"""Create table for card archetypes (goblins, vampires, elves, etc.)."""
print("\n📊 Creating mtg_card_archetypes table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_archetypes (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
archetype VARCHAR(100) NOT NULL,
strength INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, archetype)
)
""")
indexes = [
"idx_archetypes_card_id ON mtg_card_archetypes(card_id)",
"idx_archetypes_archetype ON mtg_card_archetypes(archetype)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card archetypes table created")
def create_card_themes_table(self):
"""Create table for card themes (storm, tokens, mill, etc.)."""
print("\n📊 Creating mtg_card_themes table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_themes (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
theme VARCHAR(100) NOT NULL,
strength INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, theme)
)
""")
indexes = [
"idx_themes_card_id ON mtg_card_themes(card_id)",
"idx_themes_theme ON mtg_card_themes(theme)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card themes table created")
def create_card_relationships_table(self):
"""Create table for general card relationships."""
print("\n📊 Creating mtg_card_relationships table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_relationships (
id SERIAL PRIMARY KEY,
card_a_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
card_b_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
relationship_type VARCHAR(50) NOT NULL,
-- Types: synergy, combo, counter, evolution, partner, support, rival
strength INTEGER DEFAULT 1,
-- Strength: 1-5 (how strong the relationship is)
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_a_id, card_b_id, relationship_type)
)
""")
indexes = [
"idx_relationships_card_a ON mtg_card_relationships(card_a_id)",
"idx_relationships_card_b ON mtg_card_relationships(card_b_id)",
"idx_relationships_type ON mtg_card_relationships(relationship_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card relationships table created")
def create_card_synergies_table(self):
"""Create table for card synergies with detailed scoring."""
print("\n📊 Creating mtg_card_synergies table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_synergies (
id SERIAL PRIMARY KEY,
card_a_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
card_b_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
synergy_type VARCHAR(50) NOT NULL,
-- Types: mana_base, mechanic_support, archetype_support,
-- combo_partner, counter_partner, evolution_chain
strength INTEGER NOT NULL CHECK (strength BETWEEN 1 AND 5),
-- 1: Weak synergy, 5: Essential synergy
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_a_id, card_b_id, synergy_type)
)
""")
indexes = [
"idx_synergies_card_a ON mtg_card_synergies(card_a_id)",
"idx_synergies_card_b ON mtg_card_synergies(card_b_id)",
"idx_synergies_type ON mtg_card_synergies(synergy_type)",
"idx_synergies_strength ON mtg_card_synergies(strength)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card synergies table created")
def create_card_counters_table(self):
"""Create table for cards that counter each other."""
print("\n📊 Creating mtg_card_counters table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_counters (
id SERIAL PRIMARY KEY,
card_a_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
card_b_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
counter_type VARCHAR(50) NOT NULL,
-- Types: direct_counter, disadvantage, outclass, counter_role
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_a_id, card_b_id, counter_type)
)
""")
indexes = [
"idx_counters_card_a ON mtg_card_counters(card_a_id)",
"idx_counters_card_b ON mtg_card_counters(card_b_id)",
"idx_counters_type ON mtg_card_counters(counter_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card counters table created")
def create_card_evolution_table(self):
"""Create table for evolution chains (cards that transform/evolve)."""
print("\n📊 Creating mtg_card_evolution table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_evolution (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
evolved_card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
evolution_type VARCHAR(50) NOT NULL,
-- Types: transform, evolve, double_sided, modal_dfc
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, evolved_card_id, evolution_type)
)
""")
indexes = [
"idx_evolution_card_id ON mtg_card_evolution(card_id)",
"idx_evolution_evolved_id ON mtg_card_evolution(evolved_card_id)",
"idx_evolution_type ON mtg_card_evolution(evolution_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card evolution table created")
def create_card_partners_table(self):
"""Create table for card partnerships (commander partners, etc.)."""
print("\n📊 Creating mtg_card_partners table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_partners (
id SERIAL PRIMARY KEY,
card_a_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
card_b_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
partnership_type VARCHAR(50) NOT NULL,
-- Types: commander_partner, double_faced, companion, partner_commander
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_a_id, card_b_id, partnership_type)
)
""")
indexes = [
"idx_partners_card_a ON mtg_card_partners(card_a_id)",
"idx_partners_card_b ON mtg_card_partners(card_b_id)",
"idx_partners_type ON mtg_card_partners(partnership_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card partners table created")
def create_card_mana_relations_table(self):
"""Create table for land/mana relationships."""
print("\n📊 Creating mtg_card_mana_relations table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_mana_relations (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
land_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
mana_type VARCHAR(10) NOT NULL,
-- Types: produces, taps_for, fetches, searches, enters_tapped
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, land_id, mana_type)
)
""")
indexes = [
"idx_mana_card_id ON mtg_card_mana_relations(card_id)",
"idx_mana_land_id ON mtg_card_mana_relations(land_id)",
"idx_mana_type ON mtg_card_mana_relations(mana_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card mana relations table created")
def create_card_set_relations_table(self):
"""Create table for set/theme relationships."""
print("\n📊 Creating mtg_card_set_relations table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_set_relations (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
set_id INTEGER REFERENCES mtg_sets(id) ON DELETE CASCADE,
theme VARCHAR(100) NOT NULL,
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, set_id, theme)
)
""")
indexes = [
"idx_setrel_card_id ON mtg_card_set_relations(card_id)",
"idx_setrel_set_id ON mtg_card_set_relations(set_id)",
"idx_setrel_theme ON mtg_card_set_relations(theme)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card set relations table created")
def create_card_power_relations_table(self):
"""Create table for power/toughness relationships."""
print("\n📊 Creating mtg_card_power_relations table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_power_relations (
id SERIAL PRIMARY KEY,
card_a_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
card_b_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
relation_type VARCHAR(50) NOT NULL,
-- Types: outclasses, matches, underclasses, counters_power
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_a_id, card_b_id, relation_type)
)
""")
indexes = [
"idx_power_card_a ON mtg_card_power_relations(card_a_id)",
"idx_power_card_b ON mtg_card_power_relations(card_b_id)",
"idx_power_type ON mtg_card_power_relations(relation_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card power relations table created")
def create_card_history_table(self):
"""Create table for card history and legacy relationships."""
print("\n📊 Creating mtg_card_history table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_history (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
related_card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
history_type VARCHAR(50) NOT NULL,
-- Types: reprinted_in, previous_version, alternative_art,
-- superseded_by, predecessor
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, related_card_id, history_type)
)
""")
indexes = [
"idx_history_card_id ON mtg_card_history(card_id)",
"idx_history_related_id ON mtg_card_history(related_card_id)",
"idx_history_type ON mtg_card_history(history_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card history table created")
def create_card_interaction_stats_table(self):
"""Create summary statistics table for card interactions."""
print("\n📊 Creating mtg_card_interaction_stats table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_interaction_stats (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
total_synergies INTEGER DEFAULT 0,
total_counters INTEGER DEFAULT 0,
total_evolution INTEGER DEFAULT 0,
total_partners INTEGER DEFAULT 0,
total_mechanics INTEGER DEFAULT 0,
total_archetypes INTEGER DEFAULT 0,
total_themes INTEGER DEFAULT 0,
avg_synergy_strength DECIMAL(3,2) DEFAULT 0.00,
max_synergy_strength INTEGER DEFAULT 0,
primary_archetype VARCHAR(100),
primary_theme VARCHAR(100),
computed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id)
)
""")
indexes = [
"idx_stats_card_id ON mtg_card_interaction_stats(card_id)",
"idx_stats_total_synergies ON mtg_card_interaction_stats(total_synergies)",
"idx_stats_primary_archetype ON mtg_card_interaction_stats(primary_archetype)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card interaction stats table created")
def populate_mechanics_from_type_line(self):
"""Populate mechanics from card type lines and oracle text."""
print("\n🔄 Populating mechanics from type lines...")
# Define mechanics to look for in type lines
mechanics_map = {
'Flying': 'flying',
'Flying feet': 'flying',
'First strike': 'first_strike',
'Double strike': 'double_strike',
'Deathtouch': 'deathtouch',
'Lifelink': 'lifelink',
'Haste': 'haste',
'Trample': 'trample',
'Menace': 'menace',
'Vigilance': 'vigilance',
'Reach': 'reach',
'Indestructible': 'indestructible',
'Hexproof': 'hexproof',
'Shroud': 'shroud',
'Defender': 'defender',
'Etrata, the Silencer': 'first_strike', # Just as example
'Landfall': 'landfall',
'Delve': 'delve',
'Soulshift': 'soulshift',
'Suspend': 'suspend',
'Convoke': 'convoke',
'Rampage': 'rampage',
'Toxic': 'toxic',
'Crew': 'crew',
'Equip': 'equip',
'Annihilator': 'annihilator',
'Boltwall': 'boltwall',
'Boltwing': 'boltwing',
'Spectacle': 'spectacle',
'Prowess': 'prowess',
'Aftermath': 'aftermath',
'Adapt': 'adapt',
'Archon': 'archon',
'Amplify': 'amplify',
'Arrest': 'arrest',
'Awaken': 'awaken',
'Band with': 'banding',
'Bestow': 'bestow',
'Borrow': 'borrow',
'Burst': 'burst',
'Channel': 'channel',
'Clash': 'clash',
'Codex': 'codex',
'Crawl': 'crawl',
'Crew': 'crew',
'Curse': 'curse',
'Day': 'day_night',
'Decay': 'decay',
'Defiant': 'defiant',
'Demolish': 'demolish',
'Detain': 'detain',
'Detect': 'detect',
'Devour': 'devour',
'Disguise': 'disguise',
'Disturb': 'disturb',
'Dome': 'dome',
'Double strike': 'double_strike',
'Dredge': 'dredge',
'Emerge': 'emerge',
'Encore': 'encore',
'Endure': 'endure',
'Evoke': 'evoke',
'Evolve': 'evolve',
'Exalted': 'exalted',
'Exile': 'exile',
'Exploit': 'exploit',
'Extort': 'extort',
'Fairy': 'fairy',
'Fanatic': 'fanatic',
'Fathom': 'fathom',
'Fear': 'fear',
'Feline': 'feline',
'Flash': 'flash',
'Flight': 'flight',
'Foretell': 'foretell',
'Frenzy': 'frenzy',
'Fumble': 'fumble',
'Galvanize': 'galvanize',
'Gateway': 'gateway',
'Genesis': 'genesis',
'Graft': 'graft',
'Grave': 'grave',
'Grit': 'grit',
'Guardian': 'guardian',
'Harvest': 'harvest',
'Healer': 'healer',
'Heroic': 'heroic',
'Hideaway': 'hideaway',
'Hinterland': 'hinterland',
'Hoard': 'hoard',
'Hour': 'hour',
'Illusion': 'illusion',
'Immortal': 'immortal',
'Impulse': 'impulse',
'Inspiration': 'inspiration',
'Instill': 'instill',
'Iron': 'iron',
'Junk': 'junk',
'Kicker': 'kicker',
'Knight': 'knight',
'Land': 'land',
'Leech': 'leech',
'Lich': 'lich',
'Lifespan': 'lifespan',
'Lightning': 'lightning',
'Living': 'living',
'Lurk': 'lurk',
'Madness': 'madness',
'Manifest': 'manifest',
'Map': 'map',
'Meld': 'meld',
'Miracle': 'miracle',
'Mitosis': 'mitosis',
'Modular': 'modular',
'Moon': 'moon',
'Mother': 'mother',
'Morph': 'morph',
'Mutate': 'mutate',
'Ninja': 'ninja',
'Night': 'night',
'Nightmare': 'nightmare',
'Pact': 'pact',
'Paradox': 'paradox',
'Persist': 'persist',
'Pillage': 'pillage',
'Pivot': 'pivot',
'Planar': 'planar',
'Polar': 'polar',
'Pour': 'pour',
'Prey': 'prey',
'Prey': 'prey',
'Priest': 'priest',
'Primer': 'primer',
'Probe': 'probe',
'Prosperity': 'prosperity',
'Psychic': 'psychic',
'Puppet': 'puppet',
'Quest': 'quest',
'Quote': 'quote',
'Rage': 'rage',
'Raid': 'raid',
'Raise': 'raise',
'Rally': 'rally',
'Rapid': 'rapid',
'Rat': 'rat',
'Rebound': 'rebound',
'Reckless': 'reckless',
'Recoup': 'recoup',
'Reflect': 'reflect',
'Refresh': 'refresh',
'Replicate': 'replicate',
'Reverberate': 'reverberate',
'Reveillant': 'reviviant',
'Rift': 'rift',
'Rip': 'rip',
'Ritual': 'ritual',
'Rite': 'rite',
'Rogue': 'rogue',
'Savant': 'savant',
'Scavenge': 'scavenge',
'Seek': 'seek',
'Shadow': 'shadow',
'Shards': 'shards',
'Skulk': 'skulk',
'Smelt': 'smelt',
'Snap': 'snap',
'Snow': 'snow',
'Spectacle': 'spectacle',
'Splice': 'splice',
'Spore': 'spore',
'Sprawl': 'sprawl',
'Stabilize': 'stabilize',
'Stasis': 'stasis',
'Storm': 'storm',
'Story': 'story',
'Substitute': 'substitute',
'Sunder': 'sunder',
'Surge': 'surge',
'Survive': 'survive',
'Swarm': 'swarm',
'Symbiosis': 'symbiosis',
'Synchronized': 'synchronized',
'Synth': 'synth',
'Table': 'table',
'Taint': 'taint',
'Tank': 'tank',
'Thorn': 'thorn',
'Thwart': 'thwart',
'Time': 'time',
'Tinker': 'tinker',
'Toxin': 'toxin',
'Trail': 'trail',
'Transfigure': 'transfigure',
'Transform': 'transform',
'Transport': 'transport',
'Trouble': 'trouble',
'Tunnel': 'tunnel',
'Unearth': 'unearth',
'Unleash': 'unleash',
'Unmask': 'unmask',
'Unstoppable': 'unstoppable',
'Urborg': 'urborg',
'Urgent': 'urgent',
'Utility': 'utility',
'Vengeful': 'vengeful',
'Vanish': 'vanish',
'Vanish': 'vanish',
'Venom': 'venom',
'Victory': 'victory',
'Villainous': 'villainous',
'Vitalize': 'vitalize',
'Void': 'void',
'Voyage': 'voyage',
'Ward': 'ward',
'Watch': 'watch',
'Weave': 'weave',
'Wed': 'wed',
'Whammy': 'whammy',
'Wild': 'wild',
'Will': 'will',
'Wisp': 'wisp',
'Witch': 'witch',
'Woe': 'woe',
'Wounded': 'wounded',
'Wrap': 'wrap',
'Wrought': 'wrought',
'Wurm': 'wurm',
'Wythe': 'wythe',
}
# Insert mechanics from type lines
self.conn.execute(text("""
INSERT INTO mtg_card_mechanics (card_id, mechanic)
SELECT DISTINCT c.id, LOWER(UNNEST(string_to_array(c.subtypes, ',')))
FROM mtg_cards c
WHERE c.subtypes IS NOT NULL
AND c.subtypes != ''
AND c.subtypes != 'null'
AND LOWER(UNNEST(string_to_array(c.subtypes, ','))) IN (
'flying', 'first_strike', 'double_strike', 'deathtouch', 'lifelink',
'haste', 'trample', 'menace', 'vigilance', 'reach', 'indestructible',
'hexproof', 'shroud', 'defender', 'landfall', 'delve', 'soulshift',
'suspend', 'convoke', 'rampage', 'toxic', 'crew', 'equip', 'annihilator',
'spectacle', 'prowess', 'aftermath', 'adapt', 'amplify', 'awaken',
'banding', 'bestow', 'burst', 'channel', 'clash', 'crawl', 'curse',
'day_night', 'decay', 'defiant', 'demolish', 'detain', 'detect',
'devour', 'disguise', 'disturb', 'dome', 'double_strike', 'dredge',
'emerge', 'encore', 'endure', 'evoke', 'evolve', 'exalted', 'exile',
'exploit', 'extort', 'fairy', 'fanatic', 'fathom', 'fear', 'feline',
'flash', 'flight', 'foretell', 'frenzy', 'fumble', 'galvanize',
'gateway', 'genesis', 'graft', 'grave', 'grit', 'guardian', 'harvest',
'healer', 'heroic', 'hideaway', 'hinterland', 'hoard', 'hour', 'illusion',
'immortal', 'impulse', 'inspiration', 'instill', 'iron', 'junk', 'kicker',
'knight', 'land', 'leech', 'lich', 'lifespan', 'lightning', 'living',
'lurk', 'madness', 'manifest', 'map', 'meld', 'miracle', 'mitosis',
'modular', 'moon', 'mother', 'morph', 'mutate', 'ninja', 'night',
'nightmare', 'pact', 'paradox', 'persist', 'pillage', 'pivot', 'planar',
'polar', 'pour', 'prey', 'priest', 'primer', 'probe', 'prosperity',
'psychic', 'puppet', 'quest', 'quote', 'rage', 'raid', 'raise', 'rally',
'rapid', 'rat', 'rebound', 'reckless', 'recoup', 'reflect', 'refresh',
'replicate', 'reverberate', 'reviviant', 'rift', 'rip', 'ritual', 'rite',
'rogue', 'savant', 'scavenge', 'seek', 'shadow', 'shards', 'skulk',
'smelt', 'snap', 'snow', 'spectacle', 'splice', 'spore', 'sprawl',
'stabilize', 'stasis', 'storm', 'story', 'substitute', 'sunder', 'surge',
'survive', 'swarm', 'symbiosis', 'synchronized', 'synth', 'table', 'taint',
'tank', 'thorn', 'thwart', 'time', 'tinker', 'toxin', 'trail', 'transfigure',
'transform', 'transport', 'trouble', 'tunnel', 'unearth', 'unleash', 'unmask',
'unstoppable', 'urborg', 'urgent', 'utility', 'vengeful', 'vanish', 'venom',
'victory', 'villainous', 'vitalize', 'void', 'voyage', 'ward', 'watch', 'weave',
'wed', 'whammy', 'wild', 'will', 'wisp', 'witch', 'woe', 'wounded', 'wrap',
'wrought', 'wurm', 'wythe'
)
ON CONFLICT DO NOTHING
"""))
print(" ✓ Populated mechanics from type lines")
def populate_archetypes_from_subtypes(self):
"""Populate archetypes from card subtypes."""
print("\n🔄 Populating archetypes from subtypes...")
# Define archetype mappings
archetype_map = {
'Goblin': 'goblins',
'Elf': 'elves',
'Vampire': 'vampires',
'Angel': 'angels',
'Dragon': 'dragons',
'Human': 'humans',
'Zombie': 'zombies',
'Soldier': 'soldiers',
'Knight': 'knights',
'Wizard': 'wizards',
'Spirit': 'spirits',
'Demon': 'demons',
'Snake': 'snakes',
'Cat': 'cats',
'Wolf': 'wolves',
'Bear': 'bears',
'Bird': 'birds',
'Insect': 'insects',
'Horror': 'horrors',
'Goat': 'goats',
'Ox': 'oxen',
'Elephant': 'elephants',
'Whale': 'whales',
'Shark': 'sharks',
'Fish': 'fish',
'Serpent': 'serpents',
'Lizard': 'lizards',
'Scorpion': 'scorpions',
'Spider': 'spiders',
'Rat': 'rats',
'Snake': 'snakes',
'Drake': 'drakes',
'Wyvern': 'wyverns',
'Phoenix': 'phoenixes',
'Lynx': 'lynxes',
'Jaguar': 'jaguars',
'Hydra': 'hydrae',
'Leviathan': 'leviathans',
'Kraken': 'krakens',
'Cyclops': 'cyclopes',
'Golem': 'golems',
'Homunculus': 'homunculi',
'Clay': 'clay',
'Construct': 'constructs',
'Myr': 'myr',
'Aether': 'aether',
'Pumpkin': 'pumpkins',
'Pirate': 'pirates',
'Pegasus': 'pegasuses',
'Unicorn': 'unicorns',
'Centaur': 'centaurs',
'Merfolk': 'merfolk',
'Mermaid': 'mermaids',
'Naga': 'nagas',
'Satyr': 'satyrs',
'Dryad': 'dryads',
'Treant': 'treants',
'Elemental': 'elementals',
'Fiend': 'fiends',
'Imp': 'imps',
'Faerie': 'faeries',
'Minion': 'minions',
'Abomination': 'abominations',
'Beast': 'beasts',
'Demigod': 'demigods',
'God': 'gods',
'Avatar': 'avatars',
'Guardian': 'guardians',
'Warrior': 'warriors',
'Rogue': 'rogues',
'Artificer': 'artificers',
'Bard': 'bards',
'Monk': 'monks',
'Ninja': 'ninjas',
'Samurai': 'samurai',
'Assassin': 'assassins',
'Thief': 'thieves',
'Acrobat': 'acrobats',
'Explorer': 'explorers',
'Farmer': 'farmers',
'Myth': 'myths',
'Illusion': 'illusions',
'Mirror': 'mirrors',
'Phantom': 'phantoms',
'Shapeshifter': 'shapeshifters',
'Shaman': 'shamans',
'Shark': 'sharks',
'Skeleton': 'skeletons',
'Slime': 'slimes',
'Squirrel': 'squirrels',
'Troll': 'trolls',
'Tyrannosaur': 'tyrannosaurs',
'Utility': 'utilities',
'Warrior': 'warriors',
'Wraith': 'wraiths',
'Wurm': 'wurms',
}
# Insert archetypes
self.conn.execute(text("""
INSERT INTO mtg_card_archetypes (card_id, archetype)
SELECT DISTINCT c.id, LOWER(UNNEST(string_to_array(c.subtypes, ',')))
FROM mtg_cards c
WHERE c.subtypes IS NOT NULL
AND c.subtypes != ''
AND c.subtypes != 'null'
AND LOWER(UNNEST(string_to_array(c.subtypes, ','))) IN (
'goblin', 'elf', 'vampire', 'angel', 'dragon', 'human', 'zombie',
'soldier', 'knight', 'wizard', 'spirit', 'demon', 'snake', 'cat',
'wolf', 'bear', 'bird', 'insect', 'horror', 'goat', 'ox', 'elephant',
'whale', 'shark', 'fish', 'serpent', 'lizard', 'scorpion', 'spider',
'rat', 'drake', 'wyvern', 'phoenix', 'lynx', 'jaguar', 'hydra',
'leviathan', 'kraken', 'cyclops', 'golem', 'homunculus', 'clay',
'construct', 'myr', 'aether', 'pumpkin', 'pirate', 'pegasus',
'unicorn', 'centaur', 'merfolk', 'mermaid', 'naga', 'satyr', 'dryad',
'treant', 'elemental', 'fiend', 'imp', 'faerie', 'minion', 'abomination',
'beast', 'demigod', 'god', 'avatar', 'guardian', 'warrior', 'rogue',
'artificer', 'bard', 'monk', 'ninja', 'samurai', 'assassin', 'thief',
'acrobat', 'explorer', 'farmer', 'myth', 'illusion', 'mirror', 'phantom',
'shapeshifter', 'shaman', 'skeleton', 'slime', 'squirrel', 'troll',
'tyrannosaur', 'wraith', 'wurm'
)
ON CONFLICT DO NOTHING
"""))
print(" ✓ Populated archetypes from subtypes")
def populate_themes_from_oracle_text(self):
"""Populate themes from oracle text patterns."""
print("\n🔄 Populating themes from oracle text...")
# Define theme patterns to search for
theme_patterns = [
('storm', 'oracle_text LIKE \'%cast %spell%\' OR oracle_text LIKE \'%copy spell%\' OR oracle_text LIKE \'%cast additional spell%\''),
('tokens', 'oracle_text LIKE \'%create %token%\' OR oracle_text LIKE \'%put %token%\' OR oracle_text LIKE \'%you get %token%\''),
('mill', 'oracle_text LIKE \'%mill%\' OR oracle_text LIKE \'%put cards from top of your library into your graveyard%\''),
('flicker', 'oracle_text LIKE \'%exile %and return%\' OR oracle_text LIKE \'%unmark%\' OR oracle_text LIKE \'%bounce%\''),
('draw', 'oracle_text LIKE \'%draw %cards%\' OR oracle_text LIKE \'%you may draw%\''),
('life_gain', 'oracle_text LIKE \'%gain life%\' OR oracle_text LIKE \'%you gain % life%\''),
('board_wipe', 'oracle_text LIKE \'%all creatures get -%\' OR oracle_text LIKE \'%destroy all creatures%\''),
('deck_out', 'oracle_text LIKE \'%lose the game%\' OR oracle_text LIKE \'%you lose the game%\''),
('reanimate', 'oracle_text LIKE \'%put card from graveyard%\' OR oracle_text LIKE \'%return card from graveyard%\''),
('countermagic', 'oracle_text LIKE \'%counter target spell%\' OR oracle_text LIKE \'%counter target spell%\''),
('card_advantage', 'oracle_text LIKE \'%draw %card%\' OR oracle_text LIKE \'%draw two cards%\''),
('mana_acceleration', 'oracle_text LIKE \'%add %mana%\' OR oracle_text LIKE \'%add {C}%\' OR oracle_text LIKE \'%add {R}%\' OR oracle_text LIKE \'%add {U}%\' OR oracle_text LIKE \'%add {B}%\' OR oracle_text LIKE \'%add {G}%\' OR oracle_text LIKE \'%add {W}%\''),
('combat_tricks', 'oracle_text LIKE \'%gain first strike%\' OR oracle_text LIKE \'%gain trample%\' OR oracle_text LIKE \'%gain deathtouch%\' OR oracle_text LIKE \'%gain lifelink%\' OR oracle_text LIKE \'%gain vigilance%\' OR oracle_text LIKE \'%until end of turn%\''),
('etb_effects', 'oracle_text LIKE \'%when %enters the battlefield%\' OR oracle_text LIKE \'%enters the battlefield with%\' OR oracle_text LIKE \'%enters the battlefield tapped%\''),
('ltb_effects', 'oracle_text LIKE \'%when %leaves the battlefield%\' OR oracle_text LIKE \'%leaves the battlefield, exile%\'\'' ),
('synergy', 'oracle_text LIKE \'%copy %spell%\' OR oracle_text LIKE \'%create %token%\' OR oracle_text LIKE \'%gain % life%\'' ),
]
# This is a complex query, let's simplify for demonstration
# In production, you'd want to use more sophisticated NLP or pattern matching
print(" ️ Theme population requires complex pattern matching")
print(" ️ Skipping for now - can be added as a separate step")
def run_migration(self):
"""Run the full migration."""
print("=" * 60)
print("🚀 Creating Card Interaction Graph Schema")
print("=" * 60)
self.connect()
# Create all interaction tables
self.create_card_mechanics_table()
self.create_card_archetypes_table()
self.create_card_themes_table()
self.create_card_relationships_table()
self.create_card_synergies_table()
self.create_card_counters_table()
self.create_card_evolution_table()
self.create_card_partners_table()
self.create_card_mana_relations_table()
self.create_card_set_relations_table()
self.create_card_power_relations_table()
self.create_card_history_table()
self.create_card_interaction_stats_table()
# Populate some data
self.populate_mechanics_from_type_line()
self.populate_archetypes_from_subtypes()
# Skip themes for now (complex pattern matching)
# self.populate_themes_from_oracle_text()
self.disconnect()
print("\n" + "=" * 60)
print("✅ Card Interaction Graph created successfully!")
print("=" * 60)
def main():
"""Main entry point."""
graph = CardInteractionGraph()
graph.run_migration()
if __name__ == "__main__":
main()
-70
View File
@@ -1,70 +0,0 @@
"""Inspect the actual database schema from the running containers."""
import psycopg2
import json
def inspect():
conn = psycopg2.connect(
host="172.18.0.2", port=5432,
dbname="mtgdata", user="mtgonline", password="mtgonline_pass"
)
cur = conn.cursor()
# Get actual mtg_cards columns
cur.execute("""
SELECT column_name, data_type, column_default
FROM information_schema.columns
WHERE table_name = 'mtg_cards'
ORDER BY ordinal_position
""")
cards_cols = cur.fetchall()
print("=== mtg_cards columns ===")
for row in cards_cols:
print(f" {row[0]}: {row[1]} (default: {row[2]})")
# Get actual mtg_sets columns
cur.execute("""
SELECT column_name, data_type, column_default
FROM information_schema.columns
WHERE table_name = 'mtg_sets'
ORDER BY ordinal_position
""")
sets_cols = cur.fetchall()
print("\n=== mtg_sets columns ===")
for row in sets_cols:
print(f" {row[0]}: {row[1]} (default: {row[2]})")
# Check counts
cur.execute("SELECT COUNT(*) FROM mtg_cards")
print(f"\nmtg_cards count: {cur.fetchone()[0]}")
cur.execute("SELECT COUNT(*) FROM mtg_sets")
print(f"mtg_sets count: {cur.fetchone()[0]}")
# Sample card
print("\n=== Sample card (first row) ===")
cur.execute("SELECT * FROM mtg_cards LIMIT 1")
col_names = [d[0] for d in cur.description]
row = cur.fetchone()
for c, v in zip(col_names, row):
print(f" {c}: {v}")
# Sample set
print("\n=== Sample set (first row) ===")
cur.execute("SELECT * FROM mtg_sets LIMIT 1")
col_names = [d[0] for d in cur.description]
row = cur.fetchone()
for c, v in zip(col_names, row):
print(f" {c}: {v}")
# Distinct rarities
cur.execute("SELECT DISTINCT rarity FROM mtg_cards ORDER BY rarity")
print(f"\nDistinct rarities: {[r[0] for r in cur.fetchall()]}")
# Distinct layouts
cur.execute("SELECT DISTINCT layout FROM mtg_cards ORDER BY layout")
print(f"Distinct layouts: {[r[0] for r in cur.fetchall()]}")
cur.close()
conn.close()
if __name__ == "__main__":
inspect()
-314
View File
@@ -1,314 +0,0 @@
"""
MTG Card Interaction Determinator
Determines specific card interactions (synergies, counters, evolutions)
using game rules and card profiles.
"""
from typing import List, Tuple, Optional
from dataclasses import dataclass
from card_profile_extractor import CardProfile
@dataclass
class InteractionResult:
"""Result of an interaction determination."""
card_a_id: int
card_b_id: int
interaction_type: str # 'synergy', 'counter', 'evolution'
strength: int # 1-5
confidence: float # 0.0-1.0
notes: str
metadata: dict = None
def __post_init__(self):
if self.metadata is None:
self.metadata = {}
class InteractionDeterminator:
"""
Determines card interactions using game rules.
Uses deterministic rules based on:
- Shared archetypes (e.g., both are goblins)
- Supporting mechanics (e.g., one has haste, the other has trample)
- Mana compatibility (same colors work well together)
- Target/trigger relationships (one targets, the other interacts)
- Evolution chains (same card, different versions)
"""
def __init__(self):
"""Initialize the determinator."""
pass
def determine_synergies(
self, profile_a: CardProfile, profile_b: CardProfile
) -> List[InteractionResult]:
"""
Determine synergies between two cards.
Synergies are positive interactions where cards work well together.
Examples:
- Both are goblins (archetype synergy)
- One has haste, the other has trample (mechanic synergy)
- Same color identity (mana synergy)
- One targets creatures, the other buffs creatures (combo synergy)
Args:
profile_a: First card profile
profile_b: Second card profile
Returns:
List of synergy results
"""
synergies = []
# 1. Archetype synergy: both share an archetype
if profile_a.archetypes and profile_b.archetypes:
common_archetypes = set(profile_a.archetypes) & set(profile_b.archetypes)
if common_archetypes:
synergies.append(InteractionResult(
card_a_id=profile_a.id,
card_b_id=profile_b.id,
interaction_type='synergy',
strength=3,
confidence=0.95,
notes=f"Both are {', '.join(common_archetypes)}",
metadata={'common_archetypes': list(common_archetypes)}
))
# 2. Mana synergy: same color identity
if profile_a.colors and profile_b.colors:
if set(profile_a.colors) == set(profile_b.colors):
synergies.append(InteractionResult(
card_a_id=profile_a.id,
card_b_id=profile_b.id,
interaction_type='synergy',
strength=4,
confidence=0.9,
notes="Same color identity",
metadata={'colors': profile_a.colors}
))
# 3. Mechanic synergy: complementary mechanics
if profile_a.mechanics and profile_b.mechanics:
# Haste + trample = aggressive combo
if ('haste' in profile_a.mechanics and 'trample' in profile_b.mechanics) or \
('haste' in profile_b.mechanics and 'trample' in profile_a.mechanics):
synergies.append(InteractionResult(
card_a_id=profile_a.id,
card_b_id=profile_b.id,
interaction_type='synergy',
strength=4,
confidence=0.85,
notes="Haste + Trample combo",
metadata={'mechanics': ['haste', 'trample']}
))
# Lifelink + combat keywords = combat combo
combat_keywords = ['first_strike', 'double_strike', 'deathtouch', 'trample']
if ('lifelink' in profile_a.mechanics and any(k in profile_b.mechanics for k in combat_keywords)) or \
('lifelink' in profile_b.mechanics and any(k in profile_a.mechanics for k in combat_keywords)):
synergies.append(InteractionResult(
card_a_id=profile_a.id,
card_b_id=profile_b.id,
interaction_type='synergy',
strength=3,
confidence=0.8,
notes="Lifelink + combat keywords combo",
metadata={'mechanics': ['lifelink', 'combat']}
))
# 4. Combo synergy: one targets, the other interacts with targets
if profile_a.targets and profile_b.triggers:
# Card A targets creatures, Card B interacts with creature actions
if 'creature' in profile_a.targets:
creature_actions = ['enters_battlefield', 'dies', 'attacks', 'blocks']
if any(t in creature_actions for t in profile_b.triggers):
synergies.append(InteractionResult(
card_a_id=profile_a.id,
card_b_id=profile_b.id,
interaction_type='synergy',
strength=3,
confidence=0.85,
notes="Card A targets creatures, Card B interacts with creature actions",
metadata={'card_a_targets': 'creature', 'card_b_interacts': 'creature_actions'}
))
# 5. Support synergy: one has a mechanic, the other supports it
if profile_a.mechanics and profile_b.effects:
# If card B has an effect that supports card A's mechanic
if 'haste' in profile_a.mechanics and 'gain_haste' in profile_b.effects:
synergies.append(InteractionResult(
card_a_id=profile_a.id,
card_b_id=profile_b.id,
interaction_type='synergy',
strength=3,
confidence=0.8,
notes="Card B grants haste to Card A",
metadata={'mechanic': 'haste', 'effect': 'gain_haste'}
))
return synergies
def determine_counters(
self, profile_a: CardProfile, profile_b: CardProfile
) -> List[InteractionResult]:
"""
Determine counter relationships between two cards.
Counters are negative interactions where one card is disadvantaged by another.
Examples:
- Different color identities (strategic tension)
- One has higher power (stat disadvantage)
- One counters the other's strategy (counter role)
Args:
profile_a: First card profile
profile_b: Second card profile
Returns:
List of counter results
"""
counters = []
# 1. Color counter: different color identities
if profile_a.colors and profile_b.colors:
if set(profile_a.colors) != set(profile_b.colors):
counters.append(InteractionResult(
card_a_id=profile_a.id,
card_b_id=profile_b.id,
interaction_type='counter',
strength=2,
confidence=0.8,
notes="Different color identities",
metadata={'colors_a': profile_a.colors, 'colors_b': profile_b.colors}
))
# 2. Stat counter: one has significantly higher power
if profile_a.power and profile_b.power:
try:
power_a = int(profile_a.power)
power_b = int(profile_b.power)
if power_a > power_b + 1:
counters.append(InteractionResult(
card_a_id=profile_a.id,
card_b_id=profile_b.id,
interaction_type='counter',
strength=3,
confidence=0.75,
notes=f"Card A has higher power ({power_a} vs {power_b})",
metadata={'power_a': power_a, 'power_b': power_b}
))
elif power_b > power_a + 1:
counters.append(InteractionResult(
card_a_id=profile_a.id,
card_b_id=profile_b.id,
interaction_type='counter',
strength=3,
confidence=0.75,
notes=f"Card B has higher power ({power_b} vs {power_a})",
metadata={'power_a': power_a, 'power_b': power_b}
))
except (ValueError, TypeError):
pass
# 3. Counter role: one targets creatures, the other has combat keywords
if profile_a.targets and profile_b.mechanics:
if 'creature' in profile_a.targets:
combat_keywords = ['deathtouch', 'trample', 'first_strike', 'double_strike']
if any(m in combat_keywords for m in profile_b.mechanics):
counters.append(InteractionResult(
card_a_id=profile_a.id,
card_b_id=profile_b.id,
interaction_type='counter',
strength=2,
confidence=0.6,
notes="Card A targets creatures, Card B has combat keywords",
metadata={'target': 'creature', 'mechanics': profile_b.mechanics}
))
return counters
def determine_evolutions(
self, profile_a: CardProfile, profile_b: CardProfile
) -> List[InteractionResult]:
"""
Determine evolution relationships between two cards.
Evolutions track when a card has been reprinted, transformed, or evolved.
Examples:
- Same name in different sets (reprint)
- Transform pairs (different faces of same card)
- Double-sided cards
Args:
profile_a: First card profile
profile_b: Second card profile
Returns:
List of evolution results
"""
evolutions = []
# 1. Same name = reprint
if profile_a.name == profile_b.name:
evolutions.append(InteractionResult(
card_a_id=profile_a.id,
card_b_id=profile_b.id,
interaction_type='evolution',
strength=2,
confidence=0.9,
notes=f"Reprint of {profile_a.name}",
metadata={'card_name': profile_a.name}
))
# 2. Transform pairs would require checking card_faces in the database
# This is handled separately in the pipeline
return evolutions
def determine_all_interactions(
self,
profiles: List[CardProfile]
) -> dict:
"""
Determine all interactions for a batch of cards.
Args:
profiles: List of card profiles
Returns:
Dictionary with:
- synergies: list of synergy results
- counters: list of counter results
- evolutions: list of evolution results
"""
synergies = []
counters = []
evolutions = []
# Compare all pairs
for i in range(len(profiles)):
for j in range(i + 1, len(profiles)):
profile_a = profiles[i]
profile_b = profiles[j]
# Determine synergies
synergies.extend(self.determine_synergies(profile_a, profile_b))
# Determine counters
counters.extend(self.determine_counters(profile_a, profile_b))
# Determine evolutions
evolutions.extend(self.determine_evolutions(profile_a, profile_b))
return {
'synergies': synergies,
'counters': counters,
'evolutions': evolutions,
}
-491
View File
@@ -1,491 +0,0 @@
"""
MTG Card Interaction Pipeline
Orchestrates the full interaction determination and recommendation pipeline.
Handles initial loads and rolling updates.
"""
import json
import logging
from datetime import datetime
from typing import List, Dict, Optional, Tuple
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from card_profile_extractor import CardProfileExtractor, CardProfile
from interaction_determinator import InteractionDeterminator, InteractionResult
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class MTGInteractionPipeline:
"""
Main pipeline for processing card interactions.
Handles:
1. Loading cards from database
2. Extracting card profiles
3. Determining interactions (synergies, counters, evolutions)
4. Storing interactions in database
5. Updating interaction statistics
"""
def __init__(
self,
db_url: str,
min_confidence: float = 0.5,
):
"""
Initialize the pipeline.
Args:
db_url: PostgreSQL database URL
min_confidence: Minimum confidence to auto-store interactions
"""
self.db_url = db_url
self.min_confidence = min_confidence
self.engine = create_engine(db_url)
self.SessionLocal = sessionmaker(bind=self.engine)
self.profile_extractor = CardProfileExtractor()
self.determinator = InteractionDeterminator()
# Statistics
self.stats = {
'cards_processed': 0,
'interactions_determined': 0,
'interactions_stored': 0,
'interactions_review_queue': 0,
'errors': 0,
}
def load_cards_from_db(self, set_code: Optional[str] = None) -> List[Dict]:
"""
Load cards from the database.
Args:
set_code: Optional set code to filter by
Returns:
List of card dictionaries
"""
db = self.SessionLocal()
try:
if set_code:
query = text("""
SELECT c.*, s.code as set_code, s.name as set_name
FROM mtg_cards c
JOIN mtg_sets s ON c.set_id = s.id
WHERE s.code = :set_code
""")
cards = [dict(row._mapping) for row in
db.execute(query, {"set_code": set_code}).fetchall()]
else:
query = text("""
SELECT c.*, s.code as set_code, s.name as set_name
FROM mtg_cards c
JOIN mtg_sets s ON c.set_id = s.id
""")
cards = [dict(row._mapping) for row in db.execute(query).fetchall()]
logger.info(f"Loaded {len(cards)} cards from database")
return cards
finally:
db.close()
def extract_profiles(self, cards: List[Dict]) -> List[CardProfile]:
"""
Extract card profiles from card data.
Args:
cards: List of card dictionaries
Returns:
List of CardProfile objects
"""
return self.profile_extractor.extract_profiles_batch(cards)
def determine_interactions(self, profiles: List[CardProfile]) -> dict:
"""
Determine interactions for a batch of card profiles.
Args:
profiles: List of CardProfile objects
Returns:
Dictionary with synergies, counters, and evolutions
"""
return self.determinator.determine_all_interactions(profiles)
def _determine_synergy_type(self, interaction: InteractionResult) -> str:
"""
Determine synergy type from interaction metadata.
Args:
interaction: Interaction result
Returns:
Synergy type string
"""
metadata = interaction.metadata or {}
if 'common_archetypes' in metadata:
return 'archetype'
elif 'mechanics' in metadata:
return 'mechanic'
elif 'colors' in metadata:
return 'mana'
elif 'card_a_targets' in metadata:
return 'combo'
else:
return 'support'
def _determine_counter_type(self, interaction: InteractionResult) -> str:
"""
Determine counter type from interaction metadata.
Args:
interaction: Interaction result
Returns:
Counter type string
"""
metadata = interaction.metadata or {}
if 'colors_a' in metadata:
return 'color'
elif 'power_a' in metadata:
return 'stats'
else:
return 'keyword'
def _determine_evolution_type(self, interaction: InteractionResult) -> str:
"""
Determine evolution type from interaction metadata.
Args:
interaction: Interaction result
Returns:
Evolution type string
"""
metadata = interaction.metadata or {}
if 'card_name' in metadata:
return 'reprint'
else:
return 'evolution'
def store_interactions(self, interactions: dict) -> Tuple[int, int]:
"""
Store interactions in the database.
Args:
interactions: Dictionary with synergies, counters, evolutions
Returns:
Tuple of (stored_count, review_queue_count)
"""
db = self.SessionLocal()
stored = 0
review_queue = 0
try:
# Store synergies
for interaction in interactions['synergies']:
if interaction.confidence >= self.min_confidence:
synergy_type = self._determine_synergy_type(interaction)
db.execute(text("""
INSERT INTO mtg_card_synergies (
card_a_id, card_b_id, synergy_type, strength, notes, confidence
) VALUES (
:card_a, :card_b, :synergy_type, :strength, :notes, :confidence
) ON CONFLICT DO NOTHING
"""), {
"card_a": interaction.card_a_id,
"card_b": interaction.card_b_id,
"synergy_type": synergy_type,
"strength": interaction.strength,
"notes": interaction.notes,
"confidence": interaction.confidence,
})
stored += 1
else:
review_queue += 1
# Store counters
for interaction in interactions['counters']:
if interaction.confidence >= self.min_confidence:
counter_type = self._determine_counter_type(interaction)
db.execute(text("""
INSERT INTO mtg_card_counters (
card_a_id, card_b_id, counter_type, strength, notes, confidence
) VALUES (
:card_a, :card_b, :counter_type, :strength, :notes, :confidence
) ON CONFLICT DO NOTHING
"""), {
"card_a": interaction.card_a_id,
"card_b": interaction.card_b_id,
"counter_type": counter_type,
"strength": interaction.strength,
"notes": interaction.notes,
"confidence": interaction.confidence,
})
stored += 1
else:
review_queue += 1
# Store evolutions
for interaction in interactions['evolutions']:
if interaction.confidence >= self.min_confidence:
evolution_type = self._determine_evolution_type(interaction)
db.execute(text("""
INSERT INTO mtg_card_evolution (
card_id, evolved_card_id, evolution_type, strength, notes, confidence
) VALUES (
:card_id, :evolved_card_id, :evolution_type, :strength, :notes, :confidence
) ON CONFLICT DO NOTHING
"""), {
"card_id": interaction.card_a_id,
"evolved_card_id": interaction.card_b_id,
"evolution_type": evolution_type,
"strength": interaction.strength,
"notes": interaction.notes,
"confidence": interaction.confidence,
})
stored += 1
else:
review_queue += 1
db.commit()
logger.info(f"Stored {stored} interactions, {review_queue} sent to review queue")
except Exception as e:
db.rollback()
logger.error(f"Error storing interactions: {e}")
self.stats['errors'] += 1
finally:
db.close()
return stored, review_queue
def update_interaction_stats(self):
"""Update interaction statistics for all cards."""
db = self.SessionLocal()
try:
# Delete existing stats
db.execute(text("DELETE FROM mtg_card_interaction_stats"))
# Recalculate stats
db.execute(text("""
INSERT INTO mtg_card_interaction_stats (
card_id, total_synergies, total_counters, total_evolutions,
total_synergy_strength, avg_synergy_strength
)
SELECT
c.id,
COALESCE(synergies.synergy_count, 0),
COALESCE(counters.counter_count, 0),
COALESCE(evolution.evolution_count, 0),
COALESCE(synergies.total_strength, 0),
COALESCE(synergies.avg_strength, 0)
FROM mtg_cards c
LEFT JOIN (
SELECT card_a_id as card_id, COUNT(*) as synergy_count,
SUM(strength) as total_strength,
AVG(strength) as avg_strength
FROM mtg_card_synergies
GROUP BY card_a_id
) synergies ON c.id = synergies.card_id
LEFT JOIN (
SELECT card_a_id as card_id, COUNT(*) as counter_count
FROM mtg_card_counters
GROUP BY card_a_id
) counters ON c.id = counters.card_id
LEFT JOIN (
SELECT card_id as card_id, COUNT(*) as evolution_count
FROM mtg_card_evolution
GROUP BY card_id
) evolution ON c.id = evolution.card_id
"""))
db.commit()
logger.info("Updated interaction statistics")
except Exception as e:
db.rollback()
logger.error(f"Error updating interaction stats: {e}")
self.stats['errors'] += 1
finally:
db.close()
def run_initial_load(self, set_code: Optional[str] = None):
"""
Run initial load for all cards or a specific set.
This is used for the first time data is loaded into the database.
Args:
set_code: Optional set code to process
"""
logger.info("=" * 60)
logger.info("Starting Initial Load")
logger.info("=" * 60)
# Load all cards
all_cards = self.load_cards_from_db(set_code)
if not all_cards:
logger.warning("No cards found in database")
return
# Extract profiles
logger.info(f"Extracting profiles for {len(all_cards)} cards...")
profiles = self.extract_profiles(all_cards)
# Determine interactions
logger.info(f"Determining interactions for {len(profiles)} cards...")
interactions = self.determine_interactions(profiles)
logger.info(
f"Determined {len(interactions['synergies'])} synergies, "
f"{len(interactions['counters'])} counters, "
f"{len(interactions['evolutions'])} evolutions"
)
# Store interactions
stored, review_queue = self.store_interactions(interactions)
# Update statistics
self.update_interaction_stats()
# Update stats
self.stats['cards_processed'] = len(all_cards)
self.stats['interactions_determined'] = len(interactions['synergies']) + len(interactions['counters']) + len(interactions['evolutions'])
self.stats['interactions_stored'] = stored
self.stats['interactions_review_queue'] = review_queue
logger.info("=" * 60)
logger.info(f"Initial Load Complete")
logger.info(f" Cards processed: {self.stats['cards_processed']}")
logger.info(f" Interactions determined: {self.stats['interactions_determined']}")
logger.info(f" Interactions stored: {self.stats['interactions_stored']}")
logger.info(f" Interactions in review queue: {self.stats['interactions_review_queue']}")
logger.info("=" * 60)
def run_rolling_update(self, new_cards: List[Dict], set_code: Optional[str] = None):
"""
Run rolling update for new cards.
This is used when new cards are added via MTGJSON updates.
Args:
new_cards: List of new card dictionaries
set_code: Optional set code
"""
logger.info("=" * 60)
logger.info("Starting Rolling Update")
logger.info(f"New cards: {len(new_cards)}")
logger.info("=" * 60)
# Load existing cards
existing_cards = self.load_cards_from_db(set_code)
# Combine existing and new cards
all_cards = existing_cards + new_cards
# Extract profiles
logger.info(f"Extracting profiles for {len(all_cards)} cards...")
profiles = self.extract_profiles(all_cards)
# Determine interactions
logger.info(f"Determining interactions for {len(profiles)} cards...")
interactions = self.determine_interactions(profiles)
logger.info(
f"Determined {len(interactions['synergies'])} synergies, "
f"{len(interactions['counters'])} counters, "
f"{len(interactions['evolutions'])} evolutions"
)
# Store interactions
stored, review_queue = self.store_interactions(interactions)
# Update statistics
self.update_interaction_stats()
# Update stats
self.stats['cards_processed'] = len(new_cards)
self.stats['interactions_determined'] = len(interactions['synergies']) + len(interactions['counters']) + len(interactions['evolutions'])
self.stats['interactions_stored'] = stored
self.stats['interactions_review_queue'] = review_queue
logger.info("=" * 60)
logger.info(f"Rolling Update Complete")
logger.info(f" New cards processed: {self.stats['cards_processed']}")
logger.info(f" Interactions determined: {self.stats['interactions_determined']}")
logger.info(f" Interactions stored: {self.stats['interactions_stored']}")
logger.info(f" Interactions in review queue: {self.stats['interactions_review_queue']}")
logger.info("=" * 60)
def get_pipeline_stats(self) -> Dict:
"""Get pipeline statistics."""
return {
**self.stats,
'timestamp': datetime.now().isoformat(),
}
def close(self):
"""Close database connection."""
self.engine.dispose()
def main():
"""Main entry point for pipeline execution."""
import sys
# Database URL from environment or default
db_url = "postgresql+psycopg2://mtgonline:mtgonline_pass@172.18.0.2:5432/mtgdata"
# Get command line arguments
if len(sys.argv) < 2:
print("Usage: python pipeline.py [initial|rolling] [set_code]")
print(" initial: Run initial load for all cards or a specific set")
print(" rolling: Run rolling update for new cards (requires JSON input)")
sys.exit(1)
command = sys.argv[1]
set_code = sys.argv[2] if len(sys.argv) > 2 else None
# Initialize pipeline
pipeline = MTGInteractionPipeline(db_url)
try:
if command == "initial":
pipeline.run_initial_load(set_code)
elif command == "rolling":
# Read new cards from stdin (JSON)
new_cards = json.loads(sys.stdin.read())
pipeline.run_rolling_update(new_cards, set_code)
else:
print(f"Unknown command: {command}")
sys.exit(1)
# Print statistics
stats = pipeline.get_pipeline_stats()
print("\nPipeline Statistics:")
for key, value in stats.items():
print(f" {key}: {value}")
finally:
pipeline.close()
if __name__ == "__main__":
main()
-387
View File
@@ -1,387 +0,0 @@
"""
MTG Card Interaction Recommender
Generates card recommendations based on interaction data.
Provides synergy suggestions, archetype cards, and card-like-this recommendations.
"""
from typing import List, Dict, Optional
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from card_profile_extractor import CardProfileExtractor
from interaction_determinator import InteractionDeterminator
class InteractionRecommender:
"""
Generates card recommendations based on interaction data.
Provides:
- Synergy recommendations for a specific card
- Archetype cards for a given archetype
- Similar cards based on profiles
- Deck building suggestions
"""
def __init__(self, db_url: str):
"""
Initialize the recommender.
Args:
db_url: PostgreSQL database URL
"""
self.db_url = db_url
self.engine = create_engine(db_url)
self.SessionLocal = sessionmaker(bind=self.engine)
self.profile_extractor = CardProfileExtractor()
self.determinator = InteractionDeterminator()
def get_card_profile(self, card_id: int) -> Optional[CardProfile]:
"""
Get a card profile from the database.
Args:
card_id: Card ID
Returns:
CardProfile or None if not found
"""
db = self.SessionLocal()
try:
query = text("""
SELECT c.*, s.code as set_code
FROM mtg_cards c
JOIN mtg_sets s ON c.set_id = s.id
WHERE c.id = :card_id
""")
result = db.execute(query, {"card_id": card_id}).fetchone()
if result:
card_data = dict(result._mapping)
# Extract profile from raw data
profile = self.profile_extractor.extract_profile(card_data)
return profile
return None
finally:
db.close()
def get_card_by_id(self, card_id: int) -> Optional[Dict]:
"""
Get raw card data from the database.
Args:
card_id: Card ID
Returns:
Card dictionary or None if not found
"""
db = self.SessionLocal()
try:
query = text("""
SELECT c.*, s.code as set_code, s.name as set_name
FROM mtg_cards c
JOIN mtg_sets s ON c.set_id = s.id
WHERE c.id = :card_id
""")
result = db.execute(query, {"card_id": card_id}).fetchone()
if result:
return dict(result._mapping)
return None
finally:
db.close()
def get_synergies_for_card(self, card_id: int) -> List[Dict]:
"""
Get synergy data for a card from the database.
Args:
card_id: Card ID
Returns:
List of synergy dictionaries
"""
db = self.SessionLocal()
try:
query = text("""
SELECT cs.*,
ca.name as card_a_name, ca.type_line as card_a_type_line,
cb.name as card_b_name, cb.type_line as card_b_type_line
FROM mtg_card_synergies cs
JOIN mtg_cards ca ON cs.card_a_id = ca.id
JOIN mtg_cards cb ON cs.card_b_id = cb.id
WHERE cs.card_a_id = :card_id OR cs.card_b_id = :card_id
ORDER BY cs.strength DESC, cs.confidence DESC
LIMIT :limit
""")
results = db.execute(query, {
"card_id": card_id,
"limit": 100
}).fetchall()
return [dict(row._mapping) for row in results]
finally:
db.close()
def get_counters_for_card(self, card_id: int) -> List[Dict]:
"""
Get counter data for a card from the database.
Args:
card_id: Card ID
Returns:
List of counter dictionaries
"""
db = self.SessionLocal()
try:
query = text("""
SELECT cc.*,
ca.name as card_a_name, ca.type_line as card_a_type_line,
cb.name as card_b_name, cb.type_line as card_b_type_line
FROM mtg_card_counters cc
JOIN mtg_cards ca ON cc.card_a_id = ca.id
JOIN mtg_cards cb ON cc.card_b_id = cb.id
WHERE cc.card_a_id = :card_id OR cc.card_b_id = :card_id
ORDER BY cc.strength DESC, cc.confidence DESC
LIMIT :limit
""")
results = db.execute(query, {
"card_id": card_id,
"limit": 100
}).fetchall()
return [dict(row._mapping) for row in results]
finally:
db.close()
def recommend_synergies(self, card_id: int, max_results: int = 20) -> List[Dict]:
"""
Recommend cards that synergize with a given card.
Args:
card_id: Card ID to find synergies for
max_results: Maximum number of recommendations
Returns:
List of recommendation dictionaries
"""
synergies = self.get_synergies_for_card(card_id)
recommendations = []
seen_cards = set()
for synergy in synergies:
# Determine which card is the "other" card
if synergy['card_a_id'] == card_id:
other_card_id = synergy['card_b_id']
other_card_name = synergy['card_b_name']
other_card_type = synergy['card_b_type_line']
else:
other_card_id = synergy['card_a_id']
other_card_name = synergy['card_a_name']
other_card_type = synergy['card_a_type_line']
# Skip if already seen
if other_card_id in seen_cards:
continue
seen_cards.add(other_card_id)
recommendations.append({
'card_id': other_card_id,
'card_name': other_card_name,
'card_type_line': other_card_type,
'synergy_type': synergy['synergy_type'],
'strength': synergy['strength'],
'confidence': synergy['confidence'],
'notes': synergy['notes'],
'recommendation_type': 'synergy',
})
# Sort by strength and confidence
recommendations.sort(key=lambda r: (r['strength'], r['confidence']), reverse=True)
return recommendations[:max_results]
def recommend_archetype_cards(self, archetype: str, max_results: int = 20) -> List[Dict]:
"""
Recommend cards that fit a specific archetype.
Args:
archetype: Archetype name (e.g., 'goblin', 'elf')
max_results: Maximum number of recommendations
Returns:
List of recommendation dictionaries
"""
db = self.SessionLocal()
try:
query = text("""
SELECT c.*, s.code as set_code, s.name as set_name
FROM mtg_cards c
JOIN mtg_sets s ON c.set_id = s.id
WHERE c.subtypes LIKE :archetype
ORDER BY c.id
LIMIT :limit
""")
results = db.execute(query, {
"archetype": f"%{archetype}%",
"limit": max_results
}).fetchall()
recommendations = []
for result in results:
card_data = dict(result._mapping)
recommendations.append({
'card_id': card_data['id'],
'card_name': card_data['name'],
'card_type_line': card_data['type_line'],
'set_code': card_data['set_code'],
'set_name': card_data['set_name'],
'recommendation_type': 'archetype',
'archetype': archetype,
'confidence': 0.8,
'notes': f"Matches {archetype} archetype",
})
return recommendations
finally:
db.close()
def recommend_similar_cards(self, card_id: int, max_results: int = 20) -> List[Dict]:
"""
Recommend cards similar to a given card.
Args:
card_id: Card ID to find similar cards for
max_results: Maximum number of recommendations
Returns:
List of recommendation dictionaries
"""
card_data = self.get_card_by_id(card_id)
if not card_data:
return []
profile = self.profile_extractor.extract_profile(card_data)
# Get similar cards based on archetype and mechanics
db = self.SessionLocal()
try:
recommendations = []
seen_cards = set()
# Get cards with matching archetypes
if profile.archetypes:
for archetype in profile.archetypes:
query = text("""
SELECT c.*, s.code as set_code
FROM mtg_cards c
JOIN mtg_sets s ON c.set_id = s.id
WHERE c.subtypes LIKE :archetype
AND c.id != :card_id
LIMIT :limit
""")
results = db.execute(query, {
"archetype": f"%{archetype}%",
"card_id": card_id,
"limit": max_results * 2
}).fetchall()
for result in results:
card = dict(result._mapping)
if card['id'] not in seen_cards:
seen_cards.add(card['id'])
recommendations.append({
'card_id': card['id'],
'card_name': card['name'],
'card_type_line': card['type_line'],
'set_code': card['set_code'],
'recommendation_type': 'similar',
'reason': f"Same archetype: {archetype}",
'confidence': 0.7,
})
# Get cards with matching mechanics
if profile.mechanics:
for mechanic in profile.mechanics[:3]: # Limit to top 3 mechanics
query = text("""
SELECT c.*, s.code as set_code
FROM mtg_cards c
JOIN mtg_sets s ON c.set_id = s.id
WHERE c.oracle_text LIKE :mechanic
AND c.id != :card_id
LIMIT :limit
""")
results = db.execute(query, {
"mechanic": f"%{mechanic}%",
"card_id": card_id,
"limit": max_results
}).fetchall()
for result in results:
card = dict(result._mapping)
if card['id'] not in seen_cards:
seen_cards.add(card['id'])
recommendations.append({
'card_id': card['id'],
'card_name': card['name'],
'card_type_line': card['type_line'],
'set_code': card['set_code'],
'recommendation_type': 'similar',
'reason': f"Has mechanic: {mechanic}",
'confidence': 0.6,
})
# Sort by confidence
recommendations.sort(key=lambda r: r['confidence'], reverse=True)
return recommendations[:max_results]
finally:
db.close()
def get_deck_recommendations(self, card_id: int, max_results: int = 10) -> Dict:
"""
Get deck building recommendations for a card.
Args:
card_id: Card ID
max_results: Maximum number of recommendations
Returns:
Dictionary with synergy cards, archetype cards, and similar cards
"""
# Get synergy cards
synergy_cards = self.recommend_synergies(card_id, max_results)
# Get archetype cards
card_data = self.get_card_by_id(card_id)
archetype_cards = []
if card_data and card_data.get('subtypes'):
# Extract first archetype
archetypes = [a.strip() for a in card_data['subtypes'].split(',')]
if archetypes:
archetype_cards = self.recommend_archetype_cards(archetypes[0], max_results)
# Get similar cards
similar_cards = self.recommend_similar_cards(card_id, max_results)
return {
'synergy_cards': synergy_cards,
'archetype_cards': archetype_cards,
'similar_cards': similar_cards,
'total_recommendations': len(synergy_cards) + len(archetype_cards) + len(similar_cards),
}
def close(self):
"""Close database connection."""
self.engine.dispose()
-190
View File
@@ -1,190 +0,0 @@
"""
MTG Card Interaction Database Schema
Defines the database schema for storing card interactions.
Includes tables for synergies, counters, evolutions, and statistics.
"""
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime
Base = declarative_base()
class CardSynergy(Base):
"""
Synergy between two cards.
Synergies are positive interactions where cards work well together.
Examples: Archetype support, mechanic combos, mana base compatibility.
"""
__tablename__ = 'mtg_card_synergies'
id = Column(Integer, primary_key=True, autoincrement=True)
card_a_id = Column(Integer, ForeignKey('mtg_cards.id'), nullable=False)
card_b_id = Column(Integer, ForeignKey('mtg_cards.id'), nullable=False)
synergy_type = Column(String(50), nullable=False) # 'archetype', 'mechanic', 'mana', 'combo'
strength = Column(Integer, nullable=False) # 1-5 (1=weak, 5=strong)
notes = Column(String(500), nullable=True)
confidence = Column(Float, nullable=False, default=0.8)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Unique constraint to prevent duplicates
__table_args__ = (
UniqueConstraint('card_a_id', 'card_b_id', 'synergy_type', name='uq_synergy_pair_type'),
)
# Relationships
card_a = relationship('MtgCard', foreign_keys=[card_a_id])
card_b = relationship('MtgCard', foreign_keys=[card_b_id])
def __repr__(self):
return f"<CardSynergy(id={self.id}, card_a={self.card_a_id}, card_b={self.card_b_id}, type={self.synergy_type})>"
class CardCounter(Base):
"""
Counter relationship between two cards.
Counters are negative interactions where one card is disadvantaged by another.
Examples: Different color identities, outclassed stats, countered by specific spells.
"""
__tablename__ = 'mtg_card_counters'
id = Column(Integer, primary_key=True, autoincrement=True)
card_a_id = Column(Integer, ForeignKey('mtg_cards.id'), nullable=False)
card_b_id = Column(Integer, ForeignKey('mtg_cards.id'), nullable=False)
counter_type = Column(String(50), nullable=False) # 'color', 'stats', 'spell', 'keyword'
strength = Column(Integer, nullable=False) # 1-5 (1=weak, 5=strong)
notes = Column(String(500), nullable=True)
confidence = Column(Float, nullable=False, default=0.7)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Unique constraint to prevent duplicates
__table_args__ = (
UniqueConstraint('card_a_id', 'card_b_id', 'counter_type', name='uq_counter_pair_type'),
)
# Relationships
card_a = relationship('MtgCard', foreign_keys=[card_a_id])
card_b = relationship('MtgCard', foreign_keys=[card_b_id])
def __repr__(self):
return f"<CardCounter(id={self.id}, card_a={self.card_a_id}, card_b={self.card_b_id}, type={self.counter_type})>"
class CardEvolution(Base):
"""
Evolution relationship for a card.
Evolutions track when a card has been reprinted, transformed, or evolved.
Examples: Same name in different sets, transform pairs, double-sided cards.
"""
__tablename__ = 'mtg_card_evolution'
id = Column(Integer, primary_key=True, autoincrement=True)
card_id = Column(Integer, ForeignKey('mtg_cards.id'), nullable=False)
evolved_card_id = Column(Integer, ForeignKey('mtg_cards.id'), nullable=False)
evolution_type = Column(String(50), nullable=False) # 'reprint', 'transform', 'double_sided'
strength = Column(Integer, nullable=False) # 1-5 (1=weak, 5=strong)
notes = Column(String(500), nullable=True)
confidence = Column(Float, nullable=False, default=0.9)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Unique constraint to prevent duplicates
__table_args__ = (
UniqueConstraint('card_id', 'evolved_card_id', 'evolution_type', name='uq_evolution_pair_type'),
)
# Relationships
card = relationship('MtgCard', foreign_keys=[card_id])
evolved_card = relationship('MtgCard', foreign_keys=[evolved_card_id])
def __repr__(self):
return f"<CardEvolution(id={self.id}, card={self.card_id}, evolved={self.evolved_card_id}, type={self.evolution_type})>"
class CardInteractionStats(Base):
"""
Aggregated interaction statistics for a card.
Tracks total interactions, average strength, and primary archetypes/themes.
"""
__tablename__ = 'mtg_card_interaction_stats'
id = Column(Integer, primary_key=True, autoincrement=True)
card_id = Column(Integer, ForeignKey('mtg_cards.id'), nullable=False, unique=True)
# Interaction counts
total_synergies = Column(Integer, nullable=False, default=0)
total_counters = Column(Integer, nullable=False, default=0)
total_evolutions = Column(Integer, nullable=False, default=0)
total_partners = Column(Integer, nullable=False, default=0) # Cards that partner well
# Mechanic/archetype counts
total_mechanics = Column(Integer, nullable=False, default=0)
total_archetypes = Column(Integer, nullable=False, default=0)
total_themes = Column(Integer, nullable=False, default=0)
# Synergy strength metrics
avg_synergy_strength = Column(Float, nullable=False, default=0.0)
max_synergy_strength = Column(Integer, nullable=False, default=0)
# Primary archetype and theme
primary_archetype = Column(String(50), nullable=True)
primary_theme = Column(String(50), nullable=True)
# Metadata
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<CardInteractionStats(id={self.id}, card_id={self.card_id})>"
def create_interaction_tables(engine):
"""
Create all interaction tables in the database.
Args:
engine: SQLAlchemy engine
"""
Base.metadata.create_all(engine)
print("✅ Interaction tables created successfully")
def drop_interaction_tables(engine):
"""
Drop all interaction tables from the database.
Args:
engine: SQLAlchemy engine
"""
Base.metadata.drop_all(engine)
print("✅ Interaction tables dropped successfully")
if __name__ == "__main__":
# Example usage
from dotenv import load_dotenv
import os
load_dotenv()
db_url = os.getenv('MTG_DATABASE_URL', 'postgresql+psycopg2://mtgonline:mtgonline_pass@172.18.0.2:5432/mtgdata')
engine = create_engine(db_url)
# Create tables
create_interaction_tables(engine)
# Print table names
from sqlalchemy import inspect
inspector = inspect(engine)
print("\n📊 Tables created:")
for table in inspector.get_table_names():
if 'mtg_card_' in table:
print(f" - {table}")
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env python3
"""
Investigate MTG sets endpoint and image column.
Checks database schema, MTGJSON data structure, and API responses.
"""
import asyncio
import os
import sys
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
# Add project root to path
sys.path.insert(0, '/home/wall-o/projects/mtgonline/backend')
from app.core.settings import get_settings
from app.models.mtg_models import MtgSet, MtgCard
async def main():
"""Investigate the current state."""
settings = get_settings()
print("=== DATABASE CONNECTION ===")
print(f"MTG DB URL: {settings.MTG_DATABASE_URL}")
print()
engine = create_async_engine(settings.MTG_DATABASE_URL)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# Check mtg_sets table schema
print("=== MTG_SETS TABLE SCHEMA ===")
result = await session.execute(text("""
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'mtg_sets'
ORDER BY ordinal_position
"""))
for row in result.fetchall():
print(f" {row[0]}: {row[1]} (nullable: {row[2]})")
print()
print("=== SAMPLE SET DATA ===")
result = await session.execute(text("""
SELECT code, name, type, release_date, base_set_size, total_size,
is_foil_only, is_non_foil_only, digital, icon_svg_url,
parent_code, mtgo_code
FROM mtg_sets
LIMIT 1
"""))
row = result.fetchone()
if row:
cols = ['code', 'name', 'type', 'release_date', 'base_set_size', 'total_size',
'is_foil_only', 'is_non_foil_only', 'digital', 'icon_svg_url',
'parent_code', 'mtgo_code']
for col, val in zip(cols, row):
print(f" {col}: {val}")
print()
print("=== SET COUNT ===")
result = await session.execute(text("SELECT COUNT(*) FROM mtg_sets"))
count = result.scalar()
print(f" Total sets: {count}")
print()
print("=== IMAGE URL CHECK ===")
result = await session.execute(text("""
SELECT COUNT(*) FROM mtg_sets
WHERE image_url IS NOT NULL AND image_url != ''
"""))
count = result.scalar()
print(f" Sets with image_url: {count}")
print()
print("=== CHECKING FOR image_url COLUMN ===")
result = await session.execute(text("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'mtg_sets' AND column_name LIKE '%image%'
"""))
image_cols = [row[0] for row in result.fetchall()]
print(f" Image-related columns: {image_cols}")
await engine.dispose()
# Check MTGJSON data structure
print()
print("=== MTGJSON SET SCHEMA REFERENCE ===")
print("MTGJSON set.json fields related to images:")
print(" - image: Object with 'normal' and 'large' URLs")
print(" - image_png: URL to PNG image")
print(" - image_png_small: URL to small PNG image")
print(" - icon_svg_url: SVG icon URL")
print(" - symbol: Symbol image URL")
print(" - logo: Logo image URL")
print()
print("=== CONCLUSION ===")
print("The mtg_sets table needs an image_url column to store")
print("the normal-sized image URL from MTGJSON set data.")
print()
print("Steps needed:")
print("1. Add image_url column to mtg_sets table")
print("2. Update MtgSet model")
print("3. Update refresh_mtg.py to fetch image_url from set.json")
print("4. Update get_sets() and get_set_by_code() to return image_url")
if __name__ == "__main__":
asyncio.run(main())
-871
View File
@@ -1,871 +0,0 @@
"""
MTGJSON Database Migration - Fixed Version
This script:
1. Adds all MTGJSON columns to mtg_cards and mtg_sets tables
2. Populates them from existing JSON data
3. Creates the card interaction graph tables
4. Populates the interaction graph from existing data
5. Creates sample interaction data to demonstrate the system
"""
from sqlalchemy import create_engine, text
import json
DB_URL = "postgresql+psycopg2://mtgonline:mtgonline_pass@172.18.0.2:5432/mtgdata"
class MTGJSONFullMigration:
"""Complete migration for MTGJSON schema and card interaction graph."""
def __init__(self):
self.engine = create_engine(DB_URL)
self.conn = None
def connect(self):
"""Connect to database."""
self.conn = self.engine.connect()
print("✓ Connected to database")
def disconnect(self):
"""Disconnect from database."""
if self.conn:
self.conn.close()
self.engine.dispose()
print("✓ Disconnected from database")
def column_exists(self, table_name: str, column_name: str) -> bool:
"""Check if a column exists in a table."""
result = self.conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = :table AND column_name = :column
"""), {"table": table_name, "column": column_name})
return result.fetchone() is not None
def add_column(self, table_name: str, column_name: str, column_type: str):
"""Add a column to a table if it doesn't exist."""
if not self.column_exists(table_name, column_name):
self.conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}"))
print(f" ✓ Added: {table_name}.{column_name} ({column_type})")
def create_table(self, table_sql: str):
"""Create a table if it doesn't exist."""
self.conn.execute(text(table_sql))
print(f" ✓ Created table")
def create_unique_constraint(self, constraint_sql: str):
"""Create a unique constraint if it doesn't exist."""
try:
self.conn.execute(text(constraint_sql))
except:
pass # Constraint might already exist
def create_index(self, index_sql: str):
"""Create an index if it doesn't exist."""
self.conn.execute(text(f"CREATE INDEX IF NOT EXISTS {index_sql}"))
print(f" ✓ Created index: {index_sql.split(' ON ')[1].split(' ')[0]}")
def step_1_add_mtgjson_columns(self):
"""Step 1: Add all MTGJSON columns to mtg_cards and mtg_sets tables."""
print("\n" + "=" * 60)
print("STEP 1: Adding MTGJSON columns to database")
print("=" * 60)
# Add columns to mtg_cards
print("\n📝 Adding columns to mtg_cards...")
card_columns = [
("colors", "VARCHAR(20)"),
("color_identity", "VARCHAR(10)"),
("supertypes", "VARCHAR(100)"),
("types", "VARCHAR(255)"),
("subtypes", "VARCHAR(255)"),
("legalities", "JSONB"),
("prices", "JSONB"),
("card_faces", "JSONB"),
("foreign_names", "JSONB"),
("related_cards", "JSONB"),
("keywords", "JSONB"),
("promo", "BOOLEAN DEFAULT FALSE"),
("digital", "BOOLEAN DEFAULT FALSE"),
("token", "BOOLEAN DEFAULT FALSE"),
("full_art", "BOOLEAN DEFAULT FALSE"),
("border_color", "VARCHAR(20)"),
("watermark", "VARCHAR(255)"),
("loyalty", "VARCHAR(50)"),
("frame", "VARCHAR(50)"),
("frame_effects", "JSONB"),
("lang", "VARCHAR(10) DEFAULT 'en'"),
("original_release_date", "DATE"),
("original_type_line", "VARCHAR(255)"),
("security_stamp", "VARCHAR(20)"),
("is_rebalanced", "BOOLEAN DEFAULT FALSE"),
("is_starter", "BOOLEAN DEFAULT FALSE"),
("in_booster", "BOOLEAN DEFAULT FALSE"),
("mystical_archive", "BOOLEAN DEFAULT FALSE"),
]
for col_name, col_type in card_columns:
self.add_column("mtg_cards", col_name, col_type)
# Add columns to mtg_sets
print("\n📝 Adding columns to mtg_sets...")
set_columns = [
("tcgplayer_group_id", "INTEGER"),
("scryfall_id", "VARCHAR(36)"),
("status", "VARCHAR(20)"),
("name_normalized", "VARCHAR(255)"),
("block_code", "VARCHAR(10)"),
("set_codes", "JSONB"),
("card_count", "INTEGER"),
]
for col_name, col_type in set_columns:
self.add_column("mtg_sets", col_name, col_type)
print("\n✓ Step 1 complete: All MTGJSON columns added")
def step_2_populate_mtgjson_columns(self):
"""Step 2: Populate new columns from existing JSON data."""
print("\n" + "=" * 60)
print("STEP 2: Populating MTGJSON columns from JSON data")
print("=" * 60)
# Extract data from identifiers JSON
print("\n🔄 Extracting data from identifiers JSON...")
self.conn.execute(text("""
UPDATE mtg_cards
SET
border_color = identifiers->>'border',
watermark = identifiers->>'watermark',
original_release_date = identifiers->>'originalReleaseDate',
original_type_line = identifiers->>'originalTypeLine',
security_stamp = identifiers->>'securityStamp',
lang = identifiers->>'lang',
promo = COALESCE((identifiers->>'isPromo')::BOOLEAN, false),
digital = COALESCE((identifiers->>'isDigital')::BOOLEAN, false),
token = COALESCE((identifiers->>'isToken')::BOOLEAN, false)
WHERE identifiers IS NOT NULL
AND identifiers != 'null'
"""))
print(" ✓ Updated basic fields from identifiers")
# Extract type information from type_line
print("\n🔄 Extracting type hierarchy from type_line...")
self.conn.execute(text("""
UPDATE mtg_cards
SET
supertypes = CASE
WHEN type_line LIKE '%Legendary%' THEN 'Legendary'
ELSE NULL
END,
types = CASE
WHEN type_line LIKE '%Creature%' THEN 'Creature'
WHEN type_line LIKE '%Instant%' THEN 'Instant'
WHEN type_line LIKE '%Sorcery%' THEN 'Sorcery'
WHEN type_line LIKE '%Enchantment%' THEN 'Enchantment'
WHEN type_line LIKE '%Artifact%' THEN 'Artifact'
WHEN type_line LIKE '%Land%' THEN 'Land'
WHEN type_line LIKE '%Planeswalker%' THEN 'Planeswalker'
ELSE NULL
END,
subtypes = CASE
WHEN type_line LIKE '%Elf%' THEN 'Elf'
WHEN type_line LIKE '%Human%' THEN 'Human'
WHEN type_line LIKE '%Goblin%' THEN 'Goblin'
WHEN type_line LIKE '%Vampire%' THEN 'Vampire'
WHEN type_line LIKE '%Angel%' THEN 'Angel'
WHEN type_line LIKE '%Dragon%' THEN 'Dragon'
ELSE NULL
END
WHERE type_line IS NOT NULL
AND type_line != ''
"""))
print(" ✓ Updated type hierarchy from type_line")
# Extract legalities, prices, card_faces from images JSON
print("\n🔄 Extracting complex data from images JSON...")
self.conn.execute(text("""
UPDATE mtg_cards
SET
legalities = images->'legalities',
prices = images->'prices',
card_faces = images->'cardFaces',
foreign_names = images->'foreignData',
related_cards = images->'relatedCards'
WHERE images IS NOT NULL
AND images != 'null'
"""))
print(" ✓ Updated complex fields from images JSON")
# Extract colors from mana_cost
print("\n🔄 Extracting colors from mana_cost...")
self.conn.execute(text("""
UPDATE mtg_cards
SET
colors = CASE
WHEN mana_cost LIKE '%{W}%' AND mana_cost LIKE '%{U}%' THEN 'W,U'
WHEN mana_cost LIKE '%{W}%' AND mana_cost LIKE '%{B}%' THEN 'W,B'
WHEN mana_cost LIKE '%{U}%' AND mana_cost LIKE '%{B}%' THEN 'U,B'
WHEN mana_cost LIKE '%{W}%' THEN 'W'
WHEN mana_cost LIKE '%{U}%' THEN 'U'
WHEN mana_cost LIKE '%{B}%' THEN 'B'
WHEN mana_cost LIKE '%{R}%' THEN 'R'
WHEN mana_cost LIKE '%{G}%' THEN 'G'
ELSE NULL
END
WHERE mana_cost IS NOT NULL
AND mana_cost != ''
"""))
print(" ✓ Updated colors from mana_cost")
# Update loyalty for Planeswalkers
print("\n🔄 Updating loyalty for Planeswalkers...")
self.conn.execute(text("""
UPDATE mtg_cards
SET loyalty = '3'
WHERE type_line LIKE '%Planeswalker%'
AND loyalty IS NULL
"""))
print(" ✓ Updated loyalty for Planeswalkers")
self.conn.commit()
print("\n✓ Step 2 complete: All columns populated")
def step_3_create_interaction_graph(self):
"""Step 3: Create card interaction graph tables."""
print("\n" + "=" * 60)
print("STEP 3: Creating card interaction graph")
print("=" * 60)
# Card mechanics table
print("\n📊 Creating mtg_card_mechanics table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_mechanics (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
mechanic VARCHAR(100) NOT NULL,
strength INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, mechanic)
)
""")
indexes = [
"idx_mechanics_card_id ON mtg_card_mechanics(card_id)",
"idx_mechanics_mechanic ON mtg_card_mechanics(mechanic)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card mechanics table created")
# Card archetypes table
print("\n📊 Creating mtg_card_archetypes table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_archetypes (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
archetype VARCHAR(100) NOT NULL,
strength INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, archetype)
)
""")
indexes = [
"idx_archetypes_card_id ON mtg_card_archetypes(card_id)",
"idx_archetypes_archetype ON mtg_card_archetypes(archetype)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card archetypes table created")
# Card themes table
print("\n📊 Creating mtg_card_themes table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_themes (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
theme VARCHAR(100) NOT NULL,
strength INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, theme)
)
""")
indexes = [
"idx_themes_card_id ON mtg_card_themes(card_id)",
"idx_themes_theme ON mtg_card_themes(theme)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card themes table created")
# Card relationships table
print("\n📊 Creating mtg_card_relationships table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_relationships (
id SERIAL PRIMARY KEY,
card_a_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
card_b_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
relationship_type VARCHAR(50) NOT NULL,
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_a_id, card_b_id, relationship_type)
)
""")
indexes = [
"idx_relationships_card_a ON mtg_card_relationships(card_a_id)",
"idx_relationships_card_b ON mtg_card_relationships(card_b_id)",
"idx_relationships_type ON mtg_card_relationships(relationship_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card relationships table created")
# Card synergies table
print("\n📊 Creating mtg_card_synergies table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_synergies (
id SERIAL PRIMARY KEY,
card_a_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
card_b_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
synergy_type VARCHAR(50) NOT NULL,
strength INTEGER NOT NULL CHECK (strength BETWEEN 1 AND 5),
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_a_id, card_b_id, synergy_type)
)
""")
indexes = [
"idx_synergies_card_a ON mtg_card_synergies(card_a_id)",
"idx_synergies_card_b ON mtg_card_synergies(card_b_id)",
"idx_synergies_type ON mtg_card_synergies(synergy_type)",
"idx_synergies_strength ON mtg_card_synergies(strength)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card synergies table created")
# Card counters table
print("\n📊 Creating mtg_card_counters table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_counters (
id SERIAL PRIMARY KEY,
card_a_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
card_b_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
counter_type VARCHAR(50) NOT NULL,
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_a_id, card_b_id, counter_type)
)
""")
indexes = [
"idx_counters_card_a ON mtg_card_counters(card_a_id)",
"idx_counters_card_b ON mtg_card_counters(card_b_id)",
"idx_counters_type ON mtg_card_counters(counter_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card counters table created")
# Card evolution table
print("\n📊 Creating mtg_card_evolution table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_evolution (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
evolved_card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
evolution_type VARCHAR(50) NOT NULL,
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, evolved_card_id, evolution_type)
)
""")
indexes = [
"idx_evolution_card_id ON mtg_card_evolution(card_id)",
"idx_evolution_evolved_id ON mtg_card_evolution(evolved_card_id)",
"idx_evolution_type ON mtg_card_evolution(evolution_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card evolution table created")
# Card partners table
print("\n📊 Creating mtg_card_partners table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_partners (
id SERIAL PRIMARY KEY,
card_a_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
card_b_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
partnership_type VARCHAR(50) NOT NULL,
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_a_id, card_b_id, partnership_type)
)
""")
indexes = [
"idx_partners_card_a ON mtg_card_partners(card_a_id)",
"idx_partners_card_b ON mtg_card_partners(card_b_id)",
"idx_partners_type ON mtg_card_partners(partnership_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card partners table created")
# Card mana relations table
print("\n📊 Creating mtg_card_mana_relations table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_mana_relations (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
land_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
mana_type VARCHAR(10) NOT NULL,
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, land_id, mana_type)
)
""")
indexes = [
"idx_mana_card_id ON mtg_card_mana_relations(card_id)",
"idx_mana_land_id ON mtg_card_mana_relations(land_id)",
"idx_mana_type ON mtg_card_mana_relations(mana_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card mana relations table created")
# Card set relations table
print("\n📊 Creating mtg_card_set_relations table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_set_relations (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
set_id INTEGER REFERENCES mtg_sets(id) ON DELETE CASCADE,
theme VARCHAR(100) NOT NULL,
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, set_id, theme)
)
""")
indexes = [
"idx_setrel_card_id ON mtg_card_set_relations(card_id)",
"idx_setrel_set_id ON mtg_card_set_relations(set_id)",
"idx_setrel_theme ON mtg_card_set_relations(theme)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card set relations table created")
# Card power relations table
print("\n📊 Creating mtg_card_power_relations table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_power_relations (
id SERIAL PRIMARY KEY,
card_a_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
card_b_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
relation_type VARCHAR(50) NOT NULL,
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_a_id, card_b_id, relation_type)
)
""")
indexes = [
"idx_power_card_a ON mtg_card_power_relations(card_a_id)",
"idx_power_card_b ON mtg_card_power_relations(card_b_id)",
"idx_power_type ON mtg_card_power_relations(relation_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card power relations table created")
# Card history table
print("\n📊 Creating mtg_card_history table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_history (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
related_card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
history_type VARCHAR(50) NOT NULL,
strength INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, related_card_id, history_type)
)
""")
indexes = [
"idx_history_card_id ON mtg_card_history(card_id)",
"idx_history_related_id ON mtg_card_history(related_card_id)",
"idx_history_type ON mtg_card_history(history_type)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card history table created")
# Card interaction stats table
print("\n📊 Creating mtg_card_interaction_stats table...")
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_interaction_stats (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
total_synergies INTEGER DEFAULT 0,
total_counters INTEGER DEFAULT 0,
total_evolution INTEGER DEFAULT 0,
total_partners INTEGER DEFAULT 0,
total_mechanics INTEGER DEFAULT 0,
total_archetypes INTEGER DEFAULT 0,
total_themes INTEGER DEFAULT 0,
avg_synergy_strength DECIMAL(3,2) DEFAULT 0.00,
max_synergy_strength INTEGER DEFAULT 0,
primary_archetype VARCHAR(100),
primary_theme VARCHAR(100),
computed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id)
)
""")
indexes = [
"idx_stats_card_id ON mtg_card_interaction_stats(card_id)",
"idx_stats_total_synergies ON mtg_card_interaction_stats(total_synergies)",
"idx_stats_primary_archetype ON mtg_card_interaction_stats(primary_archetype)",
]
for idx in indexes:
self.create_index(idx)
print(" ✓ Card interaction stats table created")
print("\n✓ Step 3 complete: Interaction graph tables created")
def step_4_populate_interaction_graph(self):
"""Step 4: Populate interaction graph from existing data."""
print("\n" + "=" * 60)
print("STEP 4: Populating interaction graph from existing data")
print("=" * 60)
# Populate mechanics from subtypes
print("\n🔄 Populating mechanics from subtypes...")
self.conn.execute(text("""
INSERT INTO mtg_card_mechanics (card_id, mechanic)
SELECT DISTINCT c.id, LOWER(UNNEST(string_to_array(c.subtypes, ',')))
FROM mtg_cards c
WHERE c.subtypes IS NOT NULL
AND c.subtypes != 'null'
AND LOWER(UNNEST(string_to_array(c.subtypes, ','))) IN (
'flying', 'first_strike', 'double_strike', 'deathtouch', 'lifelink',
'haste', 'trample', 'menace', 'vigilance', 'reach', 'indestructible',
'hexproof', 'shroud', 'defender', 'landfall', 'delve', 'soulshift',
'suspend', 'convoke', 'rampage', 'toxic', 'crew', 'equip', 'annihilator',
'spectacle', 'prowess', 'aftermath', 'adapt', 'amplify', 'awaken',
'banding', 'bestow', 'burst', 'channel', 'clash', 'crawl', 'curse',
'day_night', 'decay', 'defiant', 'demolish', 'detain', 'detect',
'devour', 'disguise', 'disturb', 'dome', 'double_strike', 'dredge',
'emerge', 'encore', 'endure', 'evoke', 'evolve', 'exalted', 'exile',
'exploit', 'extort', 'fairy', 'fanatic', 'fathom', 'fear', 'feline',
'flash', 'flight', 'foretell', 'frenzy', 'fumble', 'galvanize',
'gateway', 'genesis', 'graft', 'grave', 'grit', 'guardian', 'harvest',
'healer', 'heroic', 'hideaway', 'hinterland', 'hoard', 'hour', 'illusion',
'immortal', 'impulse', 'inspiration', 'instill', 'iron', 'junk', 'kicker',
'knight', 'land', 'leech', 'lich', 'lifespan', 'lightning', 'living',
'lurk', 'madness', 'manifest', 'map', 'meld', 'miracle', 'mitosis',
'modular', 'moon', 'mother', 'morph', 'mutate', 'ninja', 'night',
'nightmare', 'pact', 'paradox', 'persist', 'pillage', 'pivot', 'planar',
'polar', 'pour', 'prey', 'priest', 'primer', 'probe', 'prosperity',
'psychic', 'puppet', 'quest', 'quote', 'rage', 'raid', 'raise', 'rally',
'rapid', 'rat', 'rebound', 'reckless', 'recoup', 'reflect', 'refresh',
'replicate', 'reverberate', 'reviviant', 'rift', 'rip', 'ritual', 'rite',
'rogue', 'savant', 'scavenge', 'seek', 'shadow', 'shards', 'skulk',
'smelt', 'snap', 'snow', 'spectacle', 'splice', 'spore', 'sprawl',
'stabilize', 'stasis', 'storm', 'story', 'substitute', 'sunder', 'surge',
'survive', 'swarm', 'symbiosis', 'synchronized', 'synth', 'table', 'taint',
'tank', 'thorn', 'thwart', 'time', 'tinker', 'toxin', 'trail', 'transfigure',
'transform', 'transport', 'trouble', 'tunnel', 'unearth', 'unleash', 'unmask',
'unstoppable', 'urborg', 'urgent', 'utility', 'vengeful', 'vanish', 'venom',
'victory', 'villainous', 'vitalize', 'void', 'voyage', 'ward', 'watch', 'weave',
'wed', 'whammy', 'wild', 'will', 'wisp', 'witch', 'woe', 'wounded', 'wrap',
'wrought', 'wurm', 'wythe'
)
ON CONFLICT DO NOTHING
"""))
print(" ✓ Populated mechanics from subtypes")
# Populate archetypes from subtypes
print("\n🔄 Populating archetypes from subtypes...")
self.conn.execute(text("""
INSERT INTO mtg_card_archetypes (card_id, archetype)
SELECT DISTINCT c.id, LOWER(UNNEST(string_to_array(c.subtypes, ',')))
FROM mtg_cards c
WHERE c.subtypes IS NOT NULL
AND c.subtypes != 'null'
AND LOWER(UNNEST(string_to_array(c.subtypes, ','))) IN (
'goblin', 'elf', 'vampire', 'angel', 'dragon', 'human', 'zombie',
'soldier', 'knight', 'wizard', 'spirit', 'demon', 'snake', 'cat',
'wolf', 'bear', 'bird', 'insect', 'horror', 'goat', 'ox', 'elephant',
'whale', 'shark', 'fish', 'serpent', 'lizard', 'scorpion', 'spider',
'rat', 'drake', 'wyvern', 'phoenix', 'lynx', 'jaguar', 'hydra',
'leviathan', 'kraken', 'cyclops', 'golem', 'homunculus', 'clay',
'construct', 'myr', 'aether', 'pumpkin', 'pirate', 'pegasus',
'unicorn', 'centaur', 'merfolk', 'mermaid', 'naga', 'satyr', 'dryad',
'treant', 'elemental', 'fiend', 'imp', 'faerie', 'minion', 'abomination',
'beast', 'demigod', 'god', 'avatar', 'guardian', 'warrior', 'rogue',
'artificer', 'bard', 'monk', 'ninja', 'samurai', 'assassin', 'thief',
'acrobat', 'explorer', 'farmer', 'myth', 'illusion', 'mirror', 'phantom',
'shapeshifter', 'shaman', 'skeleton', 'slime', 'squirrel', 'troll',
'tyrannosaur', 'wraith', 'wurm'
)
ON CONFLICT DO NOTHING
"""))
print(" ✓ Populated archetypes from subtypes")
self.conn.commit()
print("\n✓ Step 4 complete: Interaction graph populated")
def step_5_create_sample_interactions(self):
"""Step 5: Create sample interactions to demonstrate the system."""
print("\n" + "=" * 60)
print("STEP 5: Creating sample interactions")
print("=" * 60)
# Get a sample of cards to create interactions between
result = self.conn.execute(text("""
SELECT id, name, subtypes, types, colors
FROM mtg_cards
WHERE subtypes IS NOT NULL AND subtypes != 'null'
LIMIT 50
""")).fetchall()
if len(result) < 2:
print(" ️ Not enough cards with subtypes to create sample interactions")
return
print(f" ✓ Found {len(result)} cards with subtypes")
# Create sample synergies between cards with same archetype
print("\n🔄 Creating sample synergies...")
# Group cards by archetype
archetype_cards = {}
for card_id, name, subtypes, types, colors in result:
if subtypes:
for archetype in [a.strip() for a in subtypes.split(',') if a.strip()]:
if archetype not in archetype_cards:
archetype_cards[archetype] = []
archetype_cards[archetype].append(card_id)
# Create synergies between cards of the same archetype
synergy_count = 0
for archetype, card_ids in archetype_cards.items():
if len(card_ids) >= 2:
for i in range(len(card_ids)):
for j in range(i + 1, len(card_ids)):
self.conn.execute(text("""
INSERT INTO mtg_card_synergies (card_a_id, card_b_id, synergy_type, strength, notes)
VALUES (:card_a, :card_b, :synergy_type, :strength, :notes)
ON CONFLICT DO NOTHING
"""), {
"card_a": card_ids[i],
"card_b": card_ids[j],
"synergy_type": "archetype_support",
"strength": 3,
"notes": f"Both {archetype} cards work well together"
})
synergy_count += 1
print(f" ✓ Created {synergy_count} archetype synergies")
# Create sample counters between cards with different colors
print("\n🔄 Creating sample counters...")
counter_count = 0
for i in range(min(20, len(result))):
card_a_id = result[i][0]
card_a_colors = result[i][4]
if card_a_colors:
colors_a = [c.strip() for c in card_a_colors.split(',')]
for j in range(i + 1, min(i + 10, len(result))):
card_b_id = result[j][0]
card_b_colors = result[j][4]
if card_b_colors:
colors_b = [c.strip() for c in card_b_colors.split(',')]
# If different colors, create a counter relationship
if set(colors_a) != set(colors_b):
self.conn.execute(text("""
INSERT INTO mtg_card_counters (card_a_id, card_b_id, counter_type, strength, notes)
VALUES (:card_a, :card_b, :counter_type, :strength, :notes)
ON CONFLICT DO NOTHING
"""), {
"card_a": card_a_id,
"card_b": card_b_id,
"counter_type": "mana_disadvantage",
"strength": 2,
"notes": "Different color identities create strategic tension"
})
counter_count += 1
print(f" ✓ Created {counter_count} counter relationships")
# Create sample evolutions for cards with same name in different sets
print("\n🔄 Creating sample evolutions...")
self.conn.execute(text("""
INSERT INTO mtg_card_evolution (card_id, evolved_card_id, evolution_type, strength, notes)
SELECT DISTINCT c1.id, c2.id, 'reprinted', 2, 'Reprint in different set'
FROM mtg_cards c1
JOIN mtg_cards c2 ON c1.name = c2.name AND c1.set_id != c2.set_id
WHERE c1.subtypes IS NOT NULL AND c2.subtypes IS NOT NULL
LIMIT 50
ON CONFLICT DO NOTHING
"""))
print(" ✓ Created sample evolutions")
self.conn.commit()
print("\n✓ Step 5 complete: Sample interactions created")
def step_6_update_interaction_stats(self):
"""Step 6: Update interaction statistics for each card."""
print("\n" + "=" * 60)
print("STEP 6: Updating interaction statistics")
print("=" * 60)
# Delete existing stats
self.conn.execute(text("DELETE FROM mtg_card_interaction_stats"))
# Calculate and insert stats
self.conn.execute(text("""
INSERT INTO mtg_card_interaction_stats (
card_id, total_synergies, total_counters, total_evolution,
total_partners, total_mechanics, total_archetypes, total_themes,
avg_synergy_strength, max_synergy_strength, primary_archetype, primary_theme
)
SELECT
c.id,
COALESCE(synergies.synergy_count, 0),
COALESCE(counters.counter_count, 0),
COALESCE(evolution.evolution_count, 0),
COALESCE(partners.partner_count, 0),
COALESCE(mechanics.mechanic_count, 0),
COALESCE(archetypes.archetype_count, 0),
COALESCE(themes.theme_count, 0),
COALESCE(synergies.avg_strength, 0),
COALESCE(synergies.max_strength, 0),
archetypes.primary_archetype,
themes.primary_theme
FROM mtg_cards c
LEFT JOIN (
SELECT card_a_id as card_id, COUNT(*) as synergy_count,
AVG(strength) as avg_strength, MAX(strength) as max_strength
FROM mtg_card_synergies
GROUP BY card_a_id
) synergies ON c.id = synergies.card_id
LEFT JOIN (
SELECT card_a_id as card_id, COUNT(*) as counter_count
FROM mtg_card_counters
GROUP BY card_a_id
) counters ON c.id = counters.card_id
LEFT JOIN (
SELECT card_id as card_id, COUNT(*) as evolution_count
FROM mtg_card_evolution
GROUP BY card_id
) evolution ON c.id = evolution.card_id
LEFT JOIN (
SELECT card_a_id as card_id, COUNT(*) as partner_count
FROM mtg_card_partners
GROUP BY card_a_id
) partners ON c.id = partners.card_id
LEFT JOIN (
SELECT card_id as card_id, COUNT(*) as mechanic_count
FROM mtg_card_mechanics
GROUP BY card_id
) mechanics ON c.id = mechanics.card_id
LEFT JOIN (
SELECT card_id as card_id, COUNT(*) as archetype_count
FROM mtg_card_archetypes
GROUP BY card_id
) archetypes ON c.id = archetypes.card_id
LEFT JOIN (
SELECT card_id as card_id, COUNT(*) as theme_count
FROM mtg_card_themes
GROUP BY card_id
) themes ON c.id = themes.card_id
LEFT JOIN (
SELECT card_id, archetype as primary_archetype
FROM mtg_card_archetypes a1
WHERE id = (
SELECT MIN(a2.id)
FROM mtg_card_archetypes a2
WHERE a1.card_id = a2.card_id
)
) archetypes ON c.id = archetypes.card_id
LEFT JOIN (
SELECT card_id, theme as primary_theme
FROM mtg_card_themes t1
WHERE id = (
SELECT MIN(t2.id)
FROM mtg_card_themes t2
WHERE t1.card_id = t2.card_id
)
) themes ON c.id = themes.card_id
"""))
print(" ✓ Updated interaction statistics")
self.conn.commit()
print("\n✓ Step 6 complete: Interaction statistics updated")
def run_migration(self):
"""Run the complete migration."""
print("=" * 60)
print("🚀 Running Complete MTGJSON Migration")
print("=" * 60)
self.connect()
try:
self.step_1_add_mtgjson_columns()
self.step_2_populate_mtgjson_columns()
self.step_3_create_interaction_graph()
self.step_4_populate_interaction_graph()
self.step_5_create_sample_interactions()
self.step_6_update_interaction_stats()
self.disconnect()
print("\n" + "=" * 60)
print("✅ Complete migration finished successfully!")
print("=" * 60)
print("\n📊 Summary:")
print(" • Added 35+ MTGJSON columns to mtg_cards table")
print(" • Added 7 MTGJSON columns to mtg_sets table")
print(" • Created 13 interaction graph tables")
print(" • Populated mechanics, archetypes, and synergies")
print(" • Created sample card interactions")
except Exception as e:
print(f"\n❌ Migration failed: {e}")
raise
finally:
if self.conn:
self.conn.close()
def main():
"""Main entry point."""
migration = MTGJSONFullMigration()
migration.run_migration()
if __name__ == "__main__":
main()
-459
View File
@@ -1,459 +0,0 @@
"""
MTGJSON Database Migration Strategy
Comprehensive mapping of MTGJSON data model to PostgreSQL schema.
Strategy for Nested JSON Arrays:
1. Direct Columns: Simple scalar values (strings, numbers, booleans)
2. JSONB Columns: Complex objects/arrays that need querying (legalities, prices)
3. Related Tables: One-to-many relationships (card_faces, foreign_names, rulings)
4. Comma-Separated: Simple arrays that can be split (supertypes, types, subtypes)
"""
from sqlalchemy import create_engine, text
import json
DB_URL = "postgresql+psycopg2://mtgonline:mtgonline_pass@172.18.0.2:5432/mtgdata"
class MTGJSONMigration:
"""Migrate MTGJSON data to comprehensive PostgreSQL schema."""
def __init__(self):
self.engine = create_engine(DB_URL)
self.conn = None
def connect(self):
"""Connect to database."""
self.conn = self.engine.connect()
print("✓ Connected to database")
def disconnect(self):
"""Disconnect from database."""
if self.conn:
self.conn.close()
self.engine.dispose()
print("✓ Disconnected from database")
def column_exists(self, table_name: str, column_name: str) -> bool:
"""Check if a column exists in a table."""
result = self.conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = :table AND column_name = :column
"""), {"table": table_name, "column": column_name})
return result.fetchone() is not None
def add_column(self, table_name: str, column_name: str, column_type: str):
"""Add a column to a table if it doesn't exist."""
if not self.column_exists(table_name, column_name):
self.conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}"))
print(f" ✓ Added: {table_name}.{column_name} ({column_type})")
def create_table(self, table_sql: str):
"""Create a table if it doesn't exist."""
self.conn.execute(text(table_sql))
print(f" ✓ Created table")
def create_index(self, index_sql: str):
"""Create an index if it doesn't exist."""
self.conn.execute(text(f"CREATE INDEX IF NOT EXISTS {index_sql.split(' ON ')[1].split(' ')[0]} ON {index_sql.split(' ON ')[1].split(' ')[1]}"))
print(f" ✓ Created index")
def migrate_card_table(self):
"""Add all MTGJSON card attributes to mtg_cards table."""
print("\n📊 Migrating mtg_cards table...")
# ========================
# STRATEGY 1: Direct Columns (Simple scalar values)
# ========================
print("\n📝 Strategy 1: Direct Columns (Simple scalar values)")
direct_columns = [
# Basic card info
("name", "VARCHAR(255)"),
("mana_cost", "VARCHAR(255)"),
("type_line", "VARCHAR(255)"),
("oracle_text", "TEXT"),
("power", "VARCHAR(50)"),
("toughness", "VARCHAR(50)"),
("loyalty", "VARCHAR(50)"), # For Planeswalkers
("rarity", "VARCHAR(50)"),
("layout", "VARCHAR(50)"),
("artist", "VARCHAR(255)"),
("flavor_text", "TEXT"),
("numbers", "VARCHAR(100)"),
# MTGJSON: border, watermark
("border_color", "VARCHAR(20)"),
("watermark", "VARCHAR(255)"),
# MTGJSON: colorIdentity (single color)
("color_identity", "VARCHAR(10)"),
# MTGJSON: lang
("lang", "VARCHAR(10) DEFAULT 'en'"),
# MTGJSON: originalReleaseDate
("original_release_date", "DATE"),
# MTGJSON: originalTypeLine
("original_type_line", "VARCHAR(255)"),
# MTGJSON: securityStamp
("security_stamp", "VARCHAR(20)"),
# MTGJSON: isPromo
("promo", "BOOLEAN DEFAULT FALSE"),
# MTGJSON: isDigital
("digital", "BOOLEAN DEFAULT FALSE"),
# MTGJSON: isToken
("token", "BOOLEAN DEFAULT FALSE"),
# MTGJSON: frame
("frame", "VARCHAR(50)"),
# MTGJSON: fullArt
("full_art", "BOOLEAN DEFAULT FALSE"),
# MTGJSON: isRebalanced
("is_rebalanced", "BOOLEAN DEFAULT FALSE"),
# MTGJSON: isStarter
("is_starter", "BOOLEAN DEFAULT FALSE"),
# MTGJSON: isInBooster
("in_booster", "BOOLEAN DEFAULT FALSE"),
# MTGJSON: mysticalArchive
("mystical_archive", "BOOLEAN DEFAULT FALSE"),
]
for col_name, col_type in direct_columns:
self.add_column("mtg_cards", col_name, col_type)
# ========================
# STRATEGY 2: JSONB Columns (Complex objects/arrays)
# ========================
print("\n📦 Strategy 2: JSONB Columns (Complex objects/arrays)")
jsonb_columns = [
# MTGJSON: legalities object
# Example: {"Standard": "Legal", "Modern": "Banned", "Vintage": "Restricted"}
("legalities", "JSONB"),
# MTGJSON: prices object
# Example: {"tcgplayer": "$4.99", "low": 2.5, "mid": 4.0, "high": 6.0}
("prices", "JSONB"),
# MTGJSON: cardFaces array (for split cards, modal DFCs)
# Example: [{"name": "Card A", "oracleText": "...", "power": "2"}, {"name": "Card B", ...}]
("card_faces", "JSONB"),
# MTGJSON: foreignData array (for translations)
# Example: [{"language": "Japanese", "name": "カード名", "typeLine": "クリーチャー"}, ...]
("foreign_names", "JSONB"),
# MTGJSON: relatedCards object
# Example: {"convertedNames": ["..."], "commanderCounterparts": [...]}
("related_cards", "JSONB"),
# MTGJSON: frameEffects array
# Example: ["extendedart", "legendary", "nightmare"]
("frame_effects", "JSONB"),
# MTGJSON: keywords array
# Example: ["first strike", "trample", "vision mount"]
("keywords", "JSONB"),
# MTGJSON: set (set object)
# Example: {"name": "Commander 2021", "code": "C21", "type": "commander"}
("set", "JSONB"),
# MTGJSON: booster (booster configuration)
# Example: {"boosters": [{"content": [...], "type": "main"}]}
("booster", "JSONB"),
]
for col_name, col_type in jsonb_columns:
self.add_column("mtg_cards", col_name, col_type)
# ========================
# STRATEGY 3: Comma-Separated (Simple arrays)
# ========================
print("\n🔗 Strategy 3: Comma-Separated (Simple arrays)")
comma_separated = [
# MTGJSON: types array (e.g., ["Creature", "Human"])
("types", "VARCHAR(255)"),
# MTGJSON: subtypes array (e.g., ["Elf", "Rogue"])
("subtypes", "VARCHAR(255)"),
# MTGJSON: supertypes array (e.g., ["Legendary"])
("supertypes", "VARCHAR(100)"),
# MTGJSON: colors array (e.g., ["W", "G"]) - stored as comma-separated
("colors", "VARCHAR(20)"),
]
for col_name, col_type in comma_separated:
self.add_column("mtg_cards", col_name, col_type)
# ========================
# STRATEGY 4: Related Tables (One-to-many relationships)
# ========================
print("\n📚 Strategy 4: Related Tables (One-to-many relationships)")
# Card faces table
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_faces (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
face_number INTEGER,
name VARCHAR(255),
mana_cost VARCHAR(255),
type_line VARCHAR(255),
oracle_text TEXT,
power VARCHAR(50),
toughness VARCHAR(50),
loyalty VARCHAR(50),
flavor_text TEXT,
artist VARCHAR(255),
illustration_id VARCHAR(100),
image_uri TEXT,
image_png TEXT,
image_art_crop TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Foreign names table
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_foreign_names (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
language VARCHAR(20),
name VARCHAR(255),
type_line VARCHAR(255),
oracle_text TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Rulings table
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_rulings (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
published_date DATE,
text TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Related cards table
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_related (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
related_type VARCHAR(50),
related_id INTEGER,
related_name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Card types table (for normalized type search)
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_types (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
type_category VARCHAR(50),
type_name VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, type_category, type_name)
)
""")
# Color identity table (for multi-card color identity)
self.create_table("""
CREATE TABLE IF NOT EXISTS mtg_card_color_identity (
id SERIAL PRIMARY KEY,
card_id INTEGER REFERENCES mtg_cards(id) ON DELETE CASCADE,
color CHAR(1),
identity_type VARCHAR(20) DEFAULT 'color',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(card_id, color, identity_type)
)
""")
# ========================
# CREATE INDEXES
# ========================
print("\n🔍 Creating indexes...")
indexes = [
# Card indexes
"idx_cards_colors ON mtg_cards(colors)",
"idx_cards_color_identity ON mtg_cards(color_identity)",
"idx_cards_supertypes ON mtg_cards(supertypes)",
"idx_cards_types ON mtg_cards(types)",
"idx_cards_subtypes ON mtg_cards(subtypes)",
"idx_cards_legalities ON mtg_cards(legalities) USING GIN",
"idx_cards_prices ON mtg_cards(prices) USING GIN",
"idx_cards_card_faces ON mtg_cards(card_faces) USING GIN",
"idx_cards_foreign_names ON mtg_cards(foreign_names) USING GIN",
"idx_cards_related_cards ON mtg_cards(related_cards) USING GIN",
"idx_cards_keywords ON mtg_cards(keywords) USING GIN",
# Set indexes
"idx_sets_status ON mtg_sets(status)",
"idx_sets_block_code ON mtg_sets(block_code)",
# Related table indexes
"idx_card_faces_card_id ON mtg_card_faces(card_id)",
"idx_card_foreign_names_card_id ON mtg_card_foreign_names(card_id)",
"idx_card_rulings_card_id ON mtg_card_rulings(card_id)",
"idx_card_related_card_id ON mtg_card_related(card_id)",
"idx_card_types_card_id ON mtg_card_types(card_id)",
"idx_card_color_identity_card_id ON mtg_card_color_identity(card_id)",
]
for idx in indexes:
self.create_index(f"idx_{idx}")
print("\n✅ Card table migration complete!")
def migrate_set_table(self):
"""Add all MTGJSON set attributes to mtg_sets table."""
print("\n📊 Migrating mtg_sets table...")
# MTGJSON set attributes
set_columns = [
# Basic set info
("code", "VARCHAR(10)"),
("name", "VARCHAR(255)"),
("type", "VARCHAR(100)"),
("release_date", "DATE"),
("base_set_size", "INTEGER"),
("total_size", "INTEGER"),
("is_foil_only", "BOOLEAN"),
("is_non_foil_only", "BOOLEAN"),
("digital", "BOOLEAN"),
("icon_svg_url", "TEXT"),
("parent_code", "VARCHAR(10)"),
("mtgo_code", "VARCHAR(10)"),
# MTGJSON: tcgplayerGroupId
("tcgplayer_group_id", "INTEGER"),
# MTGJSON: scryfallId
("scryfall_id", "VARCHAR(36)"),
# MTGJSON: status (released, unreleased, etc.)
("status", "VARCHAR(20)"),
# MTGJSON: name_normalized
("name_normalized", "VARCHAR(255)"),
# MTGJSON: blockCode
("block_code", "VARCHAR(10)"),
# MTGJSON: setCodes (all set codes)
("set_codes", "JSONB"),
# MTGJSON: cardCount (total cards in set)
("card_count", "INTEGER"),
]
for col_name, col_type in set_columns:
self.add_column("mtg_sets", col_name, col_type)
print("\n✅ Set table migration complete!")
def populate_existing_data(self):
"""Populate new columns from existing JSON data."""
print("\n🔄 Populating existing data from JSON columns...")
# Extract data from identifiers JSON
self.conn.execute(text("""
UPDATE mtg_cards
SET
border_color = identifiers->>'border',
watermark = identifiers->>'watermark',
original_release_date = identifiers->>'originalReleaseDate',
original_type_line = identifiers->>'originalTypeLine',
security_stamp = identifiers->>'securityStamp',
lang = identifiers->>'lang',
promo = (identifiers->>'isPromo')::BOOLEAN,
digital = (identifiers->>'isDigital')::BOOLEAN,
token = (identifiers->>'isToken')::BOOLEAN
WHERE identifiers IS NOT NULL
AND identifiers != 'null'
AND identifiers != ''
"""))
print(" ✓ Updated basic fields from identifiers")
# Extract type information from type_line
self.conn.execute(text("""
UPDATE mtg_cards
SET
supertypes = type_line,
types = type_line,
subtypes = type_line
WHERE type_line IS NOT NULL
AND type_line != ''
"""))
print(" ✓ Updated type hierarchy from type_line")
# Extract legalities, prices, card_faces from images JSON
self.conn.execute(text("""
UPDATE mtg_cards
SET
prices = images->'prices',
card_faces = images->'cardFaces',
foreign_names = images->'foreignData',
related_cards = images->'relatedCards'
WHERE images IS NOT NULL
AND images != 'null'
AND images != ''
"""))
print(" ✓ Updated complex fields from images JSON")
self.conn.commit()
print("\n✅ Data population complete!")
def run_migration(self):
"""Run the full migration."""
print("=" * 60)
print("🚀 Starting MTGJSON Database Migration")
print("=" * 60)
self.connect()
# Migrate card table
self.migrate_card_table()
# Migrate set table
self.migrate_set_table()
# Populate existing data
self.populate_existing_data()
self.disconnect()
print("\n" + "=" * 60)
print("✅ Migration completed successfully!")
print("=" * 60)
def main():
"""Main entry point."""
migration = MTGJSONMigration()
migration.run_migration()
if __name__ == "__main__":
main()
-657
View File
@@ -1,657 +0,0 @@
"""
Card Interaction Recommendation Engine
Uses the interaction graph to provide:
- Synergy-based card recommendations
- Deck archetype suggestions
- Card combination suggestions
- "Cards like this" recommendations
"""
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass
from enum import Enum
import json
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
class RecommendationType(Enum):
"""Types of recommendations."""
SYNERGY = "synergy"
ARCHETYPE = "archetype"
COMBO = "combo"
COUNTER = "counter"
EVOLUTION = "evolution"
CARD_LIKE_THIS = "card_like_this"
@dataclass
class Recommendation:
"""A single recommendation."""
recommendation_type: str
card_id: int
card_name: str
card_type_line: str
confidence: float
score: float # Weighted score for ranking
reason: str
metadata: Dict[str, any] = None
def __post_init__(self):
if self.metadata is None:
self.metadata = {}
def to_dict(self) -> Dict:
"""Convert to dictionary for JSON serialization."""
return {
'recommendation_type': self.recommendation_type,
'card_id': self.card_id,
'card_name': self.card_name,
'card_type_line': self.card_type_line,
'confidence': self.confidence,
'score': self.score,
'reason': self.reason,
'metadata': self.metadata,
}
class RecommendationEngine:
"""
Generates card recommendations based on interaction graph data.
Uses:
- Interaction graph for synergy matching
- Card profiles for archetype/mana curve matching
- Confidence scoring for ranking recommendations
"""
def __init__(self, db_url: str, config: Optional[Dict] = None):
"""Initialize with database URL and configuration."""
self.db_url = db_url
self.config = config or {
'max_recommendations': 50,
'min_confidence': 0.5,
'min_score': 1.0,
'synergy_weight': 1.0,
'archetype_weight': 0.8,
'combo_weight': 1.2,
'counter_weight': 0.6,
'evolution_weight': 0.7,
}
# Initialize database connection
self.engine = create_engine(db_url)
self.SessionLocal = sessionmaker(bind=self.engine)
def get_card_profile(self, card_id: int) -> Optional[Dict]:
"""Get full card profile from database."""
db = self.SessionLocal()
try:
query = text("""
SELECT c.*, s.code as set_code, s.name as set_name
FROM mtg_cards c
JOIN mtg_sets s ON c.set_id = s.id
WHERE c.id = :card_id
""")
result = db.execute(query, {"card_id": card_id}).fetchone()
if result:
return dict(result._mapping)
return None
finally:
db.close()
def get_interactions_for_card(self, card_id: int) -> Dict[str, List[Dict]]:
"""Get all interactions for a specific card."""
db = self.SessionLocal()
try:
# Get synergies
synergies_query = text("""
SELECT card_a_id, card_b_id, synergy_type, strength, notes
FROM mtg_card_synergies
WHERE card_a_id = :card_id OR card_b_id = :card_id
""")
synergies = [dict(row._mapping) for row in db.execute(synergies_query, {"card_id": card_id}).fetchall()]
# Get counters
counters_query = text("""
SELECT card_a_id, card_b_id, counter_type, strength, notes
FROM mtg_card_counters
WHERE card_a_id = :card_id OR card_b_id = :card_id
""")
counters = [dict(row._mapping) for row in db.execute(counters_query, {"card_id": card_id}).fetchall()]
# Get evolutions
evolutions_query = text("""
SELECT card_id, evolved_card_id, evolution_type, strength, notes
FROM mtg_card_evolution
WHERE card_id = :card_id OR evolved_card_id = :card_id
""")
evolutions = [dict(row._mapping) for row in db.execute(evolutions_query, {"card_id": card_id}).fetchall()]
# Get archetypes
archetypes_query = text("""
SELECT archetype, strength
FROM mtg_card_archetypes
WHERE card_id = :card_id
""")
archetypes = [dict(row._mapping) for row in db.execute(archetypes_query, {"card_id": card_id}).fetchall()]
# Get mechanics
mechanics_query = text("""
SELECT mechanic, strength
FROM mtg_card_mechanics
WHERE card_id = :card_id
""")
mechanics = [dict(row._mapping) for row in db.execute(mechanics_query, {"card_id": card_id}).fetchall()]
return {
'synergies': synergies,
'counters': counters,
'evolutions': evolutions,
'archetypes': archetypes,
'mechanics': mechanics,
}
finally:
db.close()
def recommend_card_synergies(
self, card_id: int, max_results: int = 20
) -> List[Recommendation]:
"""
Recommend cards that synergize with a given card.
Looks for cards with:
- Same archetype
- Supporting mechanics
- Compatible mana costs
- Combo potential
"""
recommendations = []
card_profile = self.get_card_profile(card_id)
if not card_profile:
return recommendations
db = self.SessionLocal()
try:
# Get archetypes for this card
archetypes_query = text("""
SELECT archetype, strength
FROM mtg_card_archetypes
WHERE card_id = :card_id
""")
card_archetypes = [dict(row._mapping) for row in
db.execute(archetypes_query, {"card_id": card_id}).fetchall()]
# Get mechanics for this card
mechanics_query = text("""
SELECT mechanic, strength
FROM mtg_card_mechanics
WHERE card_id = :card_id
""")
card_mechanics = [dict(row._mapping) for row in
db.execute(mechanics_query, {"card_id": card_id}).fetchall()]
# Get synergies for this card
synergies_query = text("""
SELECT card_b_id as card_id, synergy_type, strength, notes
FROM mtg_card_synergies
WHERE card_a_id = :card_id
ORDER BY strength DESC
""")
synergy_cards = [dict(row._mapping) for row in
db.execute(synergies_query, {"card_id": card_id}).fetchall()]
# Score each synergizing card
for synergy in synergy_cards:
synergy_card_id = synergy['card_id']
# Get the other card's profile
other_card = self.get_card_profile(synergy_card_id)
if not other_card:
continue
# Calculate score based on synergy strength and other factors
score = synergy['strength'] * self.config['synergy_weight']
# Boost score if same archetype
archetype_match = False
for archetype in card_archetypes:
if archetype['archetype'] in other_card.get('subtypes', ''):
archetype_match = True
score *= 1.2
break
# Boost score if shared mechanic
mechanic_match = False
for mechanic in card_mechanics:
if mechanic['mechanic'] in other_card.get('oracle_text', '').lower():
mechanic_match = True
score *= 1.1
break
recommendations.append(Recommendation(
recommendation_type=RecommendationType.SYNERGY.value,
card_id=synergy_card_id,
card_name=other_card['name'],
card_type_line=other_card['type_line'],
confidence=0.9,
score=score,
reason=f"Synergizes with {card_profile['name']} ({synergy['synergy_type']})",
metadata={
'synergy_type': synergy['synergy_type'],
'synergy_strength': synergy['strength'],
'archetype_match': archetype_match,
'mechanic_match': mechanic_match,
}
))
# Sort by score and return top results
recommendations.sort(key=lambda r: r.score, reverse=True)
return recommendations[:max_results]
finally:
db.close()
def recommend_archetype_cards(
self, archetype: str, max_results: int = 20
) -> List[Recommendation]:
"""
Recommend cards that fit a specific archetype.
Looks for cards with:
- Matching subtype
- Supporting mechanics
- Compatible mana costs
"""
recommendations = []
db = self.SessionLocal()
try:
# Get cards with this archetype
cards_query = text("""
SELECT c.*, s.code as set_code, s.name as set_name
FROM mtg_cards c
JOIN mtg_sets s ON c.set_id = s.id
WHERE c.subtypes LIKE :archetype
LIMIT :limit
""")
cards = [dict(row._mapping) for row in
db.execute(cards_query, {
"archetype": f"%{archetype}%",
"limit": max_results * 2
}).fetchall()]
# Score each card
for card in cards:
# Calculate base score from archetype match
score = 1.0
# Boost score for cards with supporting mechanics
supporting_mechanics = []
if archetype.lower() == 'elf':
supporting_mechanics = ['landfall', 'vigilance', 'trample']
elif archetype.lower() == 'goblin':
supporting_mechanics = ['haste', 'trample', 'damage']
elif archetype.lower() == 'vampire':
supporting_mechanics = ['lifelink', 'first_strike', 'deathtouch']
elif archetype.lower() == 'angel':
supporting_mechanics = ['flying', 'lifelink', 'indestructible']
elif archetype.lower() == 'dragon':
supporting_mechanics = ['flying', 'trample', 'menace']
elif archetype.lower() == 'zombie':
supporting_mechanics = ['deathtouch', 'first_strike', 'haste']
for mechanic in supporting_mechanics:
if mechanic in card.get('oracle_text', '').lower():
score += 0.5
# Boost score for cards with good power/toughness
try:
power = int(card.get('power', 0) or 0)
toughness = int(card.get('toughness', 0) or 0)
if power >= 3 and toughness >= 3:
score += 0.5
except (ValueError, TypeError):
pass
recommendations.append(Recommendation(
recommendation_type=RecommendationType.ARCHETYPE.value,
card_id=card['id'],
card_name=card['name'],
card_type_line=card['type_line'],
confidence=0.8,
score=score,
reason=f"Matches {archetype} archetype",
metadata={
'archetype': archetype,
'supporting_mechanics': supporting_mechanics,
}
))
# Sort by score and return top results
recommendations.sort(key=lambda r: r.score, reverse=True)
return recommendations[:max_results]
finally:
db.close()
def recommend_card_combos(
self, card_id: int, max_results: int = 10
) -> List[Recommendation]:
"""
Recommend card combos involving a specific card.
Looks for cards that:
- Target the same creature
- Create powerful combinations
- Have complementary effects
"""
recommendations = []
card_profile = self.get_card_profile(card_id)
if not card_profile:
return recommendations
db = self.SessionLocal()
try:
# Get synergies that are combo partners
combos_query = text("""
SELECT card_b_id as card_id, synergy_type, strength, notes
FROM mtg_card_synergies
WHERE card_a_id = :card_id
AND synergy_type = 'COMBO_PARTNER'
ORDER BY strength DESC
""")
combo_cards = [dict(row._mapping) for row in
db.execute(combos_query, {"card_id": card_id}).fetchall()]
for combo in combo_cards:
combo_card_id = combo['card_id']
# Get the other card's profile
other_card = self.get_card_profile(combo_card_id)
if not other_card:
continue
# Calculate score based on combo strength
score = combo['strength'] * self.config['combo_weight']
recommendations.append(Recommendation(
recommendation_type=RecommendationType.COMBO.value,
card_id=combo_card_id,
card_name=other_card['name'],
card_type_line=other_card['type_line'],
confidence=0.85,
score=score,
reason=f"Combo with {card_profile['name']} ({combo['notes']})",
metadata={
'combo_notes': combo['notes'],
'combo_strength': combo['strength'],
}
))
# Sort by score and return top results
recommendations.sort(key=lambda r: r.score, reverse=True)
return recommendations[:max_results]
finally:
db.close()
def recommend_counter_cards(
self, card_id: int, max_results: int = 10
) -> List[Recommendation]:
"""
Recommend cards that counter a specific card.
Looks for cards that:
- Have counter spells
- Target the same card types
- Have relevant keywords
"""
recommendations = []
card_profile = self.get_card_profile(card_id)
if not card_profile:
return recommendations
db = self.SessionLocal()
try:
# Get cards that counter this card
counters_query = text("""
SELECT card_b_id as card_id, counter_type, strength, notes
FROM mtg_card_counters
WHERE card_a_id = :card_id
ORDER BY strength DESC
""")
counter_cards = [dict(row._mapping) for row in
db.execute(counters_query, {"card_id": card_id}).fetchall()]
for counter in counter_cards:
counter_card_id = counter['card_id']
# Get the counter card's profile
counter_card = self.get_card_profile(counter_card_id)
if not counter_card:
continue
# Calculate score based on counter strength
score = counter['strength'] * self.config['counter_weight']
recommendations.append(Recommendation(
recommendation_type=RecommendationType.COUNTER.value,
card_id=counter_card_id,
card_name=counter_card['name'],
card_type_line=counter_card['type_line'],
confidence=0.75,
score=score,
reason=f"Counters {card_profile['name']} ({counter['counter_type']})",
metadata={
'counter_type': counter['counter_type'],
'counter_strength': counter['strength'],
}
))
# Sort by score and return top results
recommendations.sort(key=lambda r: r.score, reverse=True)
return recommendations[:max_results]
finally:
db.close()
def recommend_card_like_this(
self, card_id: int, max_results: int = 20
) -> List[Recommendation]:
"""
Recommend cards similar to a given card.
Looks for cards with:
- Similar archetypes
- Similar mechanics
- Similar mana costs
- Similar power/toughness
"""
recommendations = []
card_profile = self.get_card_profile(card_id)
if not card_profile:
return recommendations
db = self.SessionLocal()
try:
# Get this card's archetypes
archetypes_query = text("""
SELECT archetype, strength
FROM mtg_card_archetypes
WHERE card_id = :card_id
""")
card_archetypes = [dict(row._mapping) for row in
db.execute(archetypes_query, {"card_id": card_id}).fetchall()]
# Get this card's mechanics
mechanics_query = text("""
SELECT mechanic, strength
FROM mtg_card_mechanics
WHERE card_id = :card_id
""")
card_mechanics = [dict(row._mapping) for row in
db.execute(mechanics_query, {"card_id": card_id}).fetchall()]
# Search for similar cards
similar_cards_query = text("""
SELECT c.*, s.code as set_code, s.name as set_name
FROM mtg_cards c
JOIN mtg_sets s ON c.set_id = s.id
WHERE c.id != :card_id
AND (c.subtypes LIKE :archetype OR c.oracle_text LIKE :mechanic)
LIMIT :limit
""")
# Get cards with matching archetypes
archetype_matches = []
for archetype in card_archetypes:
archetype_matches.extend(
[dict(row._mapping) for row in
db.execute(similar_cards_query, {
"card_id": card_id,
"archetype": f"%{archetype['archetype']}%",
"mechanic": "%",
"limit": max_results * 2
}).fetchall()]
)
# Get cards with matching mechanics
mechanic_matches = []
for mechanic in card_mechanics:
mechanic_matches.extend(
[dict(row._mapping) for row in
db.execute(similar_cards_query, {
"card_id": card_id,
"archetype": "%",
"mechanic": f"%{mechanic['mechanic']}%",
"limit": max_results * 2
}).fetchall()]
)
# Deduplicate
seen_cards = set()
all_matches = []
for card in archetype_matches + mechanic_matches:
if card['id'] not in seen_cards:
seen_cards.add(card['id'])
all_matches.append(card)
# Score each similar card
for card in all_matches:
score = 0.5
# Boost for archetype match
for archetype in card_archetypes:
if archetype['archetype'] in card.get('subtypes', ''):
score += 1.0
break
# Boost for mechanic match
for mechanic in card_mechanics:
if mechanic['mechanic'] in card.get('oracle_text', '').lower():
score += 0.5
break
# Boost for similar mana cost
try:
mana_a = int(card_profile.get('mana_cost', '0').replace('{', '').replace('}', '').replace('W', '').replace('U', '').replace('B', '').replace('R', '').replace('G', '').replace('X', '').replace('Y', ''))
mana_b = int(card.get('mana_cost', '0').replace('{', '').replace('}', '').replace('W', '').replace('U', '').replace('B', '').replace('R', '').replace('G', '').replace('X', '').replace('Y', ''))
if abs(mana_a - mana_b) <= 1:
score += 0.5
except (ValueError, TypeError):
pass
# Boost for similar power/toughness
try:
power_a = int(card_profile.get('power', 0) or 0)
power_b = int(card.get('power', 0) or 0)
toughness_a = int(card_profile.get('toughness', 0) or 0)
toughness_b = int(card.get('toughness', 0) or 0)
if abs(power_a - power_b) <= 1 and abs(toughness_a - toughness_b) <= 1:
score += 0.5
except (ValueError, TypeError):
pass
recommendations.append(Recommendation(
recommendation_type=RecommendationType.CARD_LIKE_THIS.value,
card_id=card['id'],
card_name=card['name'],
card_type_line=card['type_line'],
confidence=0.7,
score=score,
reason=f"Similar to {card_profile['name']}",
metadata={
'archetype_match': any(a['archetype'] in card.get('subtypes', '') for a in card_archetypes),
'mechanic_match': any(m['mechanic'] in card.get('oracle_text', '').lower() for m in card_mechanics),
}
))
# Sort by score and return top results
recommendations.sort(key=lambda r: r.score, reverse=True)
return recommendations[:max_results]
finally:
db.close()
def get_full_recommendations(
self, card_id: int, max_results: int = 50
) -> List[Recommendation]:
"""
Get all recommendations for a card.
Combines synergies, archetypes, combos, counters, and similar cards.
"""
all_recommendations = []
# Get synergies
synergies = self.recommend_card_synergies(card_id, max_results)
all_recommendations.extend(synergies)
# Get archetype cards
card_profile = self.get_card_profile(card_id)
if card_profile and card_profile.get('subtypes'):
archetypes = card_profile['subtypes'].split(',')
for archetype in archetypes:
archetype_cards = self.recommend_archetype_cards(archetype.strip(), max_results)
all_recommendations.extend(archetype_cards)
# Get combos
combos = self.recommend_card_combos(card_id, max_results)
all_recommendations.extend(combos)
# Get counters
counters = self.recommend_counter_cards(card_id, max_results)
all_recommendations.extend(counters)
# Get similar cards
similar = self.recommend_card_like_this(card_id, max_results)
all_recommendations.extend(similar)
# Deduplicate by card_id
seen_cards = set()
unique_recommendations = []
for rec in all_recommendations:
if rec.card_id not in seen_cards:
seen_cards.add(rec.card_id)
unique_recommendations.append(rec)
# Sort by score and return top results
unique_recommendations.sort(key=lambda r: r.score, reverse=True)
return unique_recommendations[:max_results]
def close(self):
"""Close database connection."""
self.engine.dispose()
-148
View File
@@ -1,148 +0,0 @@
#!/usr/bin/env python3
"""
MTGJSON Data Sanity Check
Validates downloaded MTGJSON files for expected sizes before upserting to database.
This prevents corrupted or incomplete data from being loaded into PostgreSQL.
"""
import asyncio
import json
import logging
from pathlib import Path
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Expected minimum file sizes (in bytes) for MTGJSON v5 files
# These are approximate minimums based on typical MTGJSON data sizes
EXPECTED_MIN_SIZES = {
"AllPrintings.json": 500 * 1024 * 1024, # 500 MB (should be 500-600 MB)
"AllSetFiles.json": 10 * 1024 * 1024, # 10 MB
"AllIdentifiers.json": 100 * 1024 * 1024, # 100 MB
"CardTypes.json": 1 * 1024 * 1024, # 1 MB
"Keywords.json": 0.5 * 1024 * 1024, # 0.5 MB
"MagicSets.json": 50 * 1024 * 1024, # 50 MB
"MagicRoots.json": 1 * 1024 * 1024, # 1 MB
"SetTranslations.json": 5 * 1024 * 1024, # 5 MB
}
def validate_file_sizes(data_dir: Path) -> dict:
"""
Validate downloaded MTGJSON files for expected sizes.
Args:
data_dir: Path to the MTGJSON data directory
Returns:
Dict with validation results
"""
results = {
"valid": True,
"files_checked": 0,
"files_valid": 0,
"files_invalid": 0,
"issues": []
}
if not data_dir.exists():
results["valid"] = False
results["issues"].append(f"Data directory does not exist: {data_dir}")
return results
# Check each expected file
for filename, min_size in EXPECTED_MIN_SIZES.items():
filepath = data_dir / filename
if not filepath.exists():
results["issues"].append(f"Missing file: {filename}")
results["valid"] = False
results["files_checked"] += 1
results["files_invalid"] += 1
continue
results["files_checked"] += 1
actual_size = filepath.stat().st_size
if actual_size < min_size:
results["valid"] = False
results["files_invalid"] += 1
results["issues"].append(
f"{filename}: {actual_size / (1024*1024):.1f} MB (minimum: {min_size / (1024*1024):.1f} MB)"
)
logger.warning(
f"File {filename} is too small: {actual_size / (1024*1024):.1f} MB "
f"(expected minimum: {min_size / (1024*1024):.1f} MB)"
)
else:
results["files_valid"] += 1
logger.info(
f"{filename}: {actual_size / (1024*1024):.1f} MB (OK)"
)
return results
async def validate_and_cleanup(data_dir: Path, max_retries: int = 3) -> bool:
"""
Validate MTGJSON files and cleanup if invalid.
Args:
data_dir: Path to the MTGJSON data directory
max_retries: Maximum number of retry attempts
Returns:
True if validation passes, False otherwise
"""
logger.info("=" * 60)
logger.info("MTGJSON Data Sanity Check")
logger.info("=" * 60)
for attempt in range(1, max_retries + 1):
logger.info(f"\nAttempt {attempt}/{max_retries}")
# Validate file sizes
results = validate_file_sizes(data_dir)
if results["valid"]:
logger.info("\n✓ All files passed validation")
logger.info(f" Checked: {results['files_checked']} files")
logger.info(f" Valid: {results['files_valid']} files")
return True
# Validation failed
logger.warning("\n✗ Validation failed:")
for issue in results["issues"]:
logger.warning(f" - {issue}")
if attempt < max_retries:
logger.warning(f"\nCleanup and retry in {60 * attempt} seconds...")
await asyncio.sleep(60 * attempt)
# Delete all downloaded files
logger.warning("Deleting downloaded files...")
for f in data_dir.glob("*"):
if f.is_file():
f.unlink()
logger.warning(f" Deleted: {f.name}")
logger.error("\n✗✗✗ All retry attempts failed ✗✗✗")
return False
if __name__ == "__main__":
import sys
# Get data directory from settings or use default
try:
sys.path.insert(0, "/app")
from app.config import get_settings
settings = get_settings()
data_dir = Path(settings.DATA_DIR)
except Exception as e:
logger.error(f"Failed to load settings: {e}")
data_dir = Path("/app/data/mtgjson")
# Run validation
asyncio.run(validate_and_cleanup(data_dir))
@@ -1,326 +0,0 @@
"""Comprehensive test of interaction_determinator.py"""
import sys
sys.path.insert(0, "/home/wall-o/projects/mtgonline/backend/scripts")
from interaction_determinator import (
InteractionDeterminator,
InteractionResult,
InteractionType,
)
det = InteractionDeterminator()
print("=" * 60)
print("TEST 1: extract_colors")
print("=" * 60)
# MTGJSON braced format
assert det.extract_colors("{1}{W}{U}") == ["W", "U"], f"Got: {det.extract_colors('{1}{W}{U}')}"
# Plain format
assert det.extract_colors("WWU") == ["W", "U"], f"Got: {det.extract_colors('WWU')}"
# Empty
assert det.extract_colors("") == []
# None
assert det.extract_colors(None) == []
# Single color
assert det.extract_colors("{R}") == ["R"]
# Multi-color
assert det.extract_colors("{W}{B}{R}") == ["W", "B", "R"]
print(" ✓ All color extraction tests passed")
print()
print("=" * 60)
print("TEST 2: extract_archetypes")
print("=" * 60)
# List input (MTGJSON format)
assert sorted(det.extract_archetypes(["Goblin", "Warrior"])) == ["goblin", "warrior"], f"Got: {det.extract_archetypes(['Goblin', 'Warrior'])}"
# String input
assert sorted(det.extract_archetypes("Goblin Warrior")) == ["goblin", "warrior"]
# Empty
assert det.extract_archetypes([]) == []
assert det.extract_archetypes("") == []
# Multiple archetypes
result = det.extract_archetypes(["Elf", "Warrior", "Knight"])
assert "elf" in result and "warrior" in result and "knight" in result, f"Got: {result}"
print(" ✓ All archetype extraction tests passed")
print()
print("=" * 60)
print("TEST 3: extract_mechanics")
print("=" * 60)
# Test that extracted mechanics work correctly
mechs = det.extract_mechanics("Creature — Elf", "Flying\nFirst strike")
assert "flying" in mechs, f"Got: {mechs}"
assert "first_strike" in mechs, f"Got: {mechs}"
# Empty
assert det.extract_mechanics("", "") == []
print(" ✓ All mechanic extraction tests passed")
print()
print("=" * 60)
print("TEST 4: extract_targets")
print("=" * 60)
targets = det.extract_targets("Destroy target creature. Draw a card.")
assert "creature" in targets
assert "draws_card" in targets
assert det.extract_targets("") == []
print(" ✓ All target extraction tests passed")
print()
print("=" * 60)
print("TEST 5: extract_triggers")
print("=" * 60)
triggers = det.extract_triggers("When this enters the battlefield, draw a card.")
assert "enters_battlefield" in triggers
assert "draws_card" in triggers
assert det.extract_triggers("") == []
print()
print("=" * 60)
print("TEST 6: extract_effects")
print("=" * 60)
effects = det.extract_effects("Target creature gains flying until end of turn.")
assert "gain_flying" in effects
assert "until_end_of_turn" in effects
assert det.extract_effects("") == []
print(" ✓ All effect extraction tests passed")
print()
print("=" * 60)
print("TEST 7: extract_card_properties")
print("=" * 60)
card = {
"id": 1,
"name": "Test Card",
"types": ["Creature", "Elf"],
"subtypes": ["Elf", "Warrior"],
"mana_cost": "{1}{W}",
"oracle_text": "Flying\nWhen this enters the battlefield, draw a card.\nTarget creature gains deathtouch until end of turn.",
"power": "2",
"toughness": "2",
"card_faces": [],
}
profile = det.extract_card_properties(card)
assert profile["id"] == 1
assert profile["colors"] == ["W"]
assert "flying" in profile["mechanics"]
assert "elf" in profile["archetypes"]
assert "creature" in profile["targets"]
assert "enters_battlefield" in profile["triggers"]
assert "draws_card" in profile["triggers"]
assert profile["power"] == 2
assert profile["toughness"] == 2
print(" ✓ All card property extraction tests passed")
print()
print("=" * 60)
print("TEST 8: determine_synergies")
print("=" * 60)
# Same archetype synergy
card_a = det.extract_card_properties({
"id": 10,
"name": "Goblin Warrior",
"types": ["Creature"],
"subtypes": ["Goblin", "Warrior"],
"mana_cost": "{R}",
"oracle_text": "Flying\nWhen this enters the battlefield, draw a card.",
"power": "1",
"toughness": "1",
"card_faces": [],
})
card_b = det.extract_card_properties({
"id": 11,
"name": "Goblin Hero",
"types": ["Creature"],
"subtypes": ["Goblin"],
"mana_cost": "{R}",
"oracle_text": "When this enters the battlefield, draw a card.",
"power": "2",
"toughness": "1",
"card_faces": [],
})
synergies = det.determine_synergies(card_a, card_b)
assert any(s.interaction_type == "archetype_support" for s in synergies), "Expected archetype_support"
print(f" ✓ Found {len(synergies)} synergies")
for s in synergies:
print(f" - {s.interaction_type}: {s.notes}")
print()
print("=" * 60)
print("TEST 9: determine_counters")
print("=" * 60)
card_c = det.extract_card_properties({
"id": 12,
"name": "Indestructible Wall",
"types": ["Creature"],
"subtypes": ["Wall"],
"mana_cost": "{2}{W}",
"oracle_text": "Indestructible",
"power": "0",
"toughness": "5",
"card_faces": [],
})
card_d = det.extract_card_properties({
"id": 13,
"name": "Deathtouch Beast",
"types": ["Creature"],
"subtypes": ["Beast"],
"mana_cost": "{1}{B}",
"oracle_text": "Deathtouch",
"power": "1",
"toughness": "1",
"card_faces": [],
})
counters = det.determine_counters(card_c, card_d)
assert any("indestructible" in c.interaction_type.lower() or "deathtouch" in c.interaction_type.lower() for c in counters), "Expected indestructible/deathtouch counter"
print(f" ✓ Found {len(counters)} counters")
for c in counters:
print(f" - {c.interaction_type}: {c.notes}")
print()
print("=" * 60)
print("TEST 10: determine_evolutions")
print("=" * 60)
card_e = det.extract_card_properties({
"id": 14,
"name": "Same Name Card",
"types": ["Creature"],
"subtypes": ["Elf"],
"mana_cost": "{G}",
"oracle_text": "Trample",
"power": "3",
"toughness": "3",
"card_faces": [],
})
card_f = det.extract_card_properties({
"id": 15,
"name": "Same Name Card",
"types": ["Creature"],
"subtypes": ["Elf"],
"mana_cost": "{G}",
"oracle_text": "Trample",
"power": "3",
"toughness": "3",
"card_faces": [],
})
evolutions = det.determine_evolutions(card_e, card_f)
assert any(e.interaction_type == "reprinted" for e in evolutions), "Expected reprint"
print(f" ✓ Found {len(evolutions)} evolutions")
for e in evolutions:
print(f" - {e.interaction_type}: {e.notes}")
print()
print("=" * 60)
print("TEST 11: determine_all_interactions (batch)")
print("=" * 60)
all_cards = [card_a, card_b, card_c, card_d, card_e, card_f]
batch = det.determine_all_interactions(all_cards)
print(f" Synergies: {len(batch['synergies'])}")
print(f" Counters: {len(batch['counters'])}")
print(f" Evolutions: {len(batch['evolutions'])}")
assert len(batch["synergies"]) > 0
assert len(batch["counters"]) > 0
print(" ✓ Batch interaction determination passed")
print()
print("=" * 60)
print("TEST 12: filter_by_confidence")
print("=" * 60)
high_conf = det.filter_by_confidence(batch["synergies"], 0.9)
assert all(s.confidence >= 0.9 for s in high_conf)
print(f" ✓ Filtered to {len(high_conf)} high-confidence synergies")
print()
print("=" * 60)
print("TEST 13: group_by_card")
print("=" * 60)
grouped = det.group_by_card(batch["synergies"])
assert all(isinstance(v, list) for v in grouped.values())
print(f" ✓ Grouped into {len(grouped)} cards")
print()
print("=" * 60)
print("TEST 14: get_interaction_summary")
print("=" * 60)
summary = det.get_interaction_summary(batch["synergies"])
assert isinstance(summary, dict)
print(f" ✓ Summary: {summary}")
print()
print("=" * 60)
print("TEST 15: Pipeline integration (raw dicts)")
print("=" * 60)
# Test with raw MTGJSON-style dicts (as the pipeline passes them)
raw_cards = [
{
"id": 100,
"name": "Goblin Warrior",
"types": ["Creature"],
"subtypes": ["Goblin", "Warrior"],
"mana_cost": "{R}",
"oracle_text": "Flying\nWhen this enters the battlefield, draw a card.",
"power": "1",
"toughness": "1",
"card_faces": [],
},
{
"id": 101,
"name": "Goblin Hero",
"types": ["Creature"],
"subtypes": ["Goblin"],
"mana_cost": "{R}",
"oracle_text": "When this enters the battlefield, draw a card.",
"power": "2",
"toughness": "1",
"card_faces": [],
},
{
"id": 102,
"name": "Indestructible Wall",
"types": ["Creature"],
"subtypes": ["Wall"],
"mana_cost": "{2}{W}",
"oracle_text": "Indestructible",
"power": "0",
"toughness": "5",
"card_faces": [],
},
{
"id": 103,
"name": "Deathtouch Beast",
"types": ["Creature"],
"subtypes": ["Beast"],
"mana_cost": "{1}{B}",
"oracle_text": "Deathtouch",
"power": "1",
"toughness": "1",
"card_faces": [],
},
]
batch = det.determine_all_interactions(raw_cards)
print(f" Synergies: {len(batch['synergies'])}")
print(f" Counters: {len(batch['counters'])}")
print(f" Evolutions: {len(batch['evolutions'])}")
assert len(batch["synergies"]) > 0
assert len(batch["counters"]) > 0
print(" ✓ Pipeline integration test passed")
print()
print("=" * 60)
print("ALL TESTS PASSED ✓")
print("=" * 60)
-226
View File
@@ -1,226 +0,0 @@
#!/usr/bin/env python3
"""
Comprehensive MTGJSON Data Verification
Checks both:
1. MTGJSON data file downloads
2. PostgreSQL database upsert status
"""
import asyncio
from pathlib import Path
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
import sys
sys.path.append("/app")
from app.services.mtgjson_manager import MTGJSONManager
from app.config import get_settings
async def verify_data_download():
"""Verify MTGJSON data files were downloaded."""
print("=" * 70)
print("MTGJSON DATA DOWNLOAD VERIFICATION")
print("=" * 70)
settings = get_settings()
data_dir = Path(settings.DATA_DIR)
print(f"\nData Directory: {data_dir}")
print(f"Directory exists: {data_dir.exists()}")
if not data_dir.exists():
print("❌ FAIL: Data directory does not exist")
return False
# Check for required files
required_files = {
"AllPrintings.json.gz": "All sets data",
"AllSetFiles.json.gz": "Set metadata",
"AllIdentifiers.json.gz": "Card identifiers",
"CardTypes.json.gz": "Card type definitions",
"Keywords.json.gz": "Card keywords",
"MagicRoots.json.gz": "Root data",
"MagicSets.json.gz": "Set data",
"SetTranslations.json.gz": "Set translations"
}
downloaded_files = []
missing_files = []
print("\nRequired MTGJSON files:")
for filename, description in required_files.items():
filepath = data_dir / filename
if filepath.exists():
size_mb = filepath.stat().st_size / (1024 * 1024)
downloaded_files.append(filename)
print(f"{filename:30} - {size_mb:8.1f} MB")
else:
missing_files.append(filename)
print(f"{filename:30} - MISSING")
print(f"\nDownload Status: {len(downloaded_files)}/{len(required_files)} files")
if missing_files:
print(f"\n❌ FAIL: Missing {len(missing_files)} required files: {', '.join(missing_files)}")
return False
# Check file sizes for sanity
allprintings_path = data_dir / "AllPrintings.json.gz"
if allprintings_path.exists():
size_mb = allprintings_path.stat().st_size / (1024 * 1024)
if size_mb < 100:
print(f"\n⚠️ WARNING: AllPrintings.json.gz is suspiciously small ({size_mb:.1f} MB). Expected ~500-600 MB")
return False
else:
print(f"\n✓ AllPrintings.json.gz size looks good: {size_mb:.1f} MB")
print("\n✓ PASS: All MTGJSON data files downloaded successfully")
return True
async def verify_database_upsert():
"""Verify MTGJSON data was properly upserted to PostgreSQL."""
print("\n" + "=" * 70)
print("POSTGRESQL DATABASE UPSERT VERIFICATION")
print("=" * 70)
settings = get_settings()
db_url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}"
db_url += f"@postgres-mtgdata:5432/{settings.POSTGRES_DB}"
print(f"\nDatabase: {settings.POSTGRES_DB}")
try:
engine = create_async_engine(db_url)
async with AsyncSession(engine) as session:
# Get all tables
result = await session.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;
"""))
tables = [row[0] for row in result.fetchall()]
print(f"\nTotal tables: {len(tables)}")
# Check MTGJSON-specific tables
mtg_tables = ['mtg_set', 'mtg_card', 'mtg_identifiers', 'mtg_keywords']
print("\nMTGJSON tables:")
for table in mtg_tables:
if table in tables:
result = await session.execute(text(f"SELECT COUNT(*) FROM {table}"))
count = result.scalar()
print(f"{table:30} - {count:8,} records")
else:
print(f"{table:30} - TABLE NOT FOUND")
# Check refresh log
if 'mtg_refresh_log' in tables:
result = await session.execute(text("""
SELECT refresh_type, status, created_at
FROM mtg_refresh_log
ORDER BY created_at DESC
LIMIT 5;
"""))
rows = result.fetchall()
if rows:
print("\nRecent refresh operations:")
for row in rows:
status_icon = "" if row[1] == 'SUCCESS' else ""
print(f" {status_icon} {row[0]:15} - {row[1]:8} - {row[2]}")
# Verify data quality
print("\nData quality checks:")
# Check for sets
if 'mtg_set' in tables:
result = await session.execute(text("""
SELECT COUNT(*) FROM mtg_set
WHERE set_name IS NOT NULL AND set_code IS NOT NULL;
"""))
valid_sets = result.scalar()
result = await session.execute(text("SELECT COUNT(*) FROM mtg_set"))
total_sets = result.scalar()
print(f" ✓ Sets: {valid_sets:,}/{total_sets:,} valid")
# Check for cards
if 'mtg_card' in tables:
result = await session.execute(text("""
SELECT COUNT(*) FROM mtg_card
WHERE name IS NOT NULL AND mtgjson_cards_id IS NOT NULL;
"""))
valid_cards = result.scalar()
result = await session.execute(text("SELECT COUNT(*) FROM mtg_card"))
total_cards = result.scalar()
print(f" ✓ Cards: {valid_cards:,}/{total_cards:,} valid")
# Check for identifiers
if 'mtg_identifiers' in tables:
result = await session.execute(text("""
SELECT COUNT(*) FROM mtg_identifiers
WHERE scryfall_id IS NOT NULL;
"""))
valid_ids = result.scalar()
result = await session.execute(text("SELECT COUNT(*) FROM mtg_identifiers"))
total_ids = result.scalar()
print(f" ✓ Identifiers: {valid_ids:,}/{total_ids:,} valid")
# Check for keywords
if 'mtg_keywords' in tables:
result = await session.execute(text("SELECT COUNT(*) FROM mtg_keywords"))
keywords_count = result.scalar()
print(f" ✓ Keywords: {keywords_count:,}")
print("\n✓ PASS: Database upsert completed successfully")
return True
except Exception as e:
print(f"\n❌ FAIL: Database error - {e}")
return False
async def main():
"""Main verification function."""
print("\n" + "=" * 70)
print("MTGJSON DATA INTEGRATION VERIFICATION")
print("=" * 70)
# Check data download
download_ok = await verify_data_download()
# Check database upsert
db_ok = await verify_database_upsert()
# Final summary
print("\n" + "=" * 70)
print("VERIFICATION SUMMARY")
print("=" * 70)
if download_ok and db_ok:
print("\n✓✓✓ ALL CHECKS PASSED ✓✓✓")
print("\nMTGJSON data has been successfully downloaded and upserted to PostgreSQL.")
print("The backend is ready to use.")
return 0
else:
print("\n❌❌❌ VERIFICATION FAILED ❌❌❌")
if not download_ok:
print("\nDownload issues:")
print(" - Some MTGJSON data files are missing or corrupted")
print(" - Run: docker exec mtgonline_backend python /app/scripts/download_mtgjson_v5.py")
if not db_ok:
print("\nDatabase issues:")
print(" - Data was not properly upserted to PostgreSQL")
print(" - Check backend logs for errors")
return 1
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)
-194
View File
@@ -1,194 +0,0 @@
#!/usr/bin/env python3
"""
MTGJSON Data Verification Script
Checks:
1. Data file download status
2. Sanity check validation
3. Database upsert status
4. File size validation
"""
import asyncio
import json
from pathlib import Path
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
import sys
sys.path.insert(0, "/app")
from app.config import get_settings
from app.services.mtgjson_manager import MTGJSONManager
# Expected minimum file sizes (in bytes)
EXPECTED_MIN_SIZES = {
"AllPrintings.json": 500 * 1024 * 1024, # 500 MB
"AllSetFiles.json": 10 * 1024 * 1024, # 10 MB
"AllIdentifiers.json": 100 * 1024 * 1024, # 100 MB
"CardTypes.json": 1 * 1024 * 1024, # 1 MB
"Keywords.json": 0.5 * 1024 * 1024, # 0.5 MB
"MagicSets.json": 50 * 1024 * 1024, # 50 MB
"MagicRoots.json": 1 * 1024 * 1024, # 1 MB
"SetTranslations.json": 5 * 1024 * 1024, # 5 MB
}
async def check_data_files(data_dir: Path) -> dict:
"""Check downloaded data files and validate sizes."""
print("=" * 70)
print("DATA FILE VERIFICATION")
print("=" * 70)
results = {
"valid": True,
"files_checked": 0,
"files_valid": 0,
"files_invalid": 0,
"issues": []
}
if not data_dir.exists():
results["valid"] = False
results["issues"].append(f"Data directory does not exist: {data_dir}")
return results
# Check each expected file
for filename, min_size in EXPECTED_MIN_SIZES.items():
filepath = data_dir / filename
if not filepath.exists():
results["issues"].append(f"Missing file: {filename}")
results["valid"] = False
results["files_checked"] += 1
results["files_invalid"] += 1
continue
results["files_checked"] += 1
actual_size = filepath.stat().st_size
if actual_size < min_size:
results["valid"] = False
results["files_invalid"] += 1
results["issues"].append(
f"{filename}: {actual_size / (1024*1024):.1f} MB (minimum: {min_size / (1024*1024):.1f} MB)"
)
print(f"{filename:30} - {actual_size / (1024*1024):8.1f} MB (too small)")
else:
results["files_valid"] += 1
print(f"{filename:30} - {actual_size / (1024*1024):8.1f} MB")
print(f"\nSummary: {results['files_valid']}/{results['files_checked']} files valid")
if results["issues"]:
print("\nIssues:")
for issue in results["issues"]:
print(f" - {issue}")
return results
async def check_database_status() -> dict:
"""Check database upsert status."""
print("\n" + "=" * 70)
print("DATABASE STATUS VERIFICATION")
print("=" * 70)
settings = get_settings()
db_url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}"
db_url += f"@postgres-mtgdata:5432/{settings.POSTGRES_DB}"
try:
engine = create_async_engine(db_url)
async with AsyncSession(engine) as session:
# Get all tables
result = await session.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;
"""))
tables = [row[0] for row in result.fetchall()]
print(f"Total tables: {len(tables)}")
# Check MTGJSON tables
mtg_tables = ['mtg_set', 'mtg_card', 'mtg_identifiers', 'mtg_keywords']
for table in mtg_tables:
if table in tables:
result = await session.execute(text(f"SELECT COUNT(*) FROM {table}"))
count = result.scalar()
print(f"{table:30} - {count:8,} records")
else:
print(f"{table:30} - TABLE NOT FOUND")
# Check refresh log
if 'mtg_refresh_log' in tables:
result = await session.execute(text("""
SELECT refresh_type, status, created_at
FROM mtg_refresh_log
ORDER BY created_at DESC
LIMIT 5;
"""))
rows = result.fetchall()
if rows:
print("\nRecent refresh operations:")
for row in rows:
status_icon = "" if row[1] == 'SUCCESS' else ""
print(f" {status_icon} {row[0]:15} - {row[1]:8} - {row[2]}")
return {"valid": True, "error": None}
except Exception as e:
print(f"\n✗ Database error: {e}")
return {"valid": False, "error": str(e)}
async def main():
"""Main verification function."""
print("\n" + "=" * 70)
print("MTGJSON DATA INTEGRATION VERIFICATION")
print("=" * 70)
settings = get_settings()
data_dir = Path(settings.DATA_DIR)
# Check data files
file_results = await check_data_files(data_dir)
# Check database
db_results = await check_database_status()
# Final summary
print("\n" + "=" * 70)
print("VERIFICATION SUMMARY")
print("=" * 70)
if file_results["valid"] and db_results["valid"]:
print("\n✓✓✓ ALL CHECKS PASSED ✓✓✓")
print("\nThe MTGJSON data has been properly downloaded and upserted.")
return 0
else:
print("\n❌ VERIFICATION FAILED")
if not file_results["valid"]:
print("\nData file issues:")
for issue in file_results["issues"]:
print(f" - {issue}")
if not db_results["valid"]:
print(f"\nDatabase issues: {db_results['error']}")
print("\nSOLUTION:")
print(" 1. Delete corrupted data files")
print(" 2. Run fresh download with sanity checks:")
print(" docker exec mtgonline_backend python /app/scripts/download_mtgjson_v5.py")
return 1
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)
-223
View File
@@ -1,223 +0,0 @@
"""
Database initialization script for MTG Online backend.
Creates all necessary tables matching the SQLAlchemy ORM models
for both databases.
"""
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
from app.core.settings import get_settings
async def setup_mtg_online_database():
"""Create tables for the mtgonline database."""
settings = get_settings()
engine = create_async_engine(settings.MTGO_DATABASE_URL)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# Create tables
tables = [
"""
CREATE TABLE IF NOT EXISTS mtgonline_users (
id SERIAL PRIMARY KEY,
username VARCHAR(64) UNIQUE NOT NULL,
password_hash VARCHAR(128) NOT NULL,
salt VARCHAR(128) NOT NULL,
email VARCHAR(255),
country VARCHAR(2),
real_name VARCHAR(128),
avatar_bmp TEXT,
privlevel VARCHAR(50) DEFAULT 'User',
is_active BOOLEAN DEFAULT TRUE,
is_banned BOOLEAN DEFAULT FALSE,
ban_reason TEXT,
ban_ends TIMESTAMP,
vip_status INTEGER DEFAULT 0,
vip_expiry TIMESTAMP,
creation_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS mtgonline_decklist_folders (
id SERIAL PRIMARY KEY,
owner_id INTEGER REFERENCES mtgonline_users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
parent_id INTEGER REFERENCES mtgonline_decklist_folders(id) ON DELETE CASCADE,
creation_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS mtgonline_decklist_files (
id SERIAL PRIMARY KEY,
folder_id INTEGER REFERENCES mtgonline_decklist_folders(id) ON DELETE CASCADE,
owner_id INTEGER REFERENCES mtgonline_users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
format VARCHAR(50) DEFAULT 'native',
creation_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS mtgonline_rooms (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
is_password_protected BOOLEAN DEFAULT FALSE,
password_hash VARCHAR(128),
creation_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS mtgonline_rooms_gametypes (
id SERIAL PRIMARY KEY,
room_id INTEGER REFERENCES mtgonline_rooms(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS mtgonline_bans (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES mtgonline_users(id) ON DELETE CASCADE,
server_id INTEGER,
reason TEXT NOT NULL,
moderators VARCHAR(255),
ip_address VARCHAR(45),
expiration_time TIMESTAMP,
active BOOLEAN DEFAULT TRUE,
creation_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS mtgonline_log (
id SERIAL PRIMARY KEY,
room_id INTEGER REFERENCES mtgonline_rooms(id) ON DELETE CASCADE,
player_id INTEGER REFERENCES mtgonline_users(id) ON DELETE CASCADE,
message TEXT NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS mtgonline_audit (
id SERIAL PRIMARY KEY,
admin_id INTEGER REFERENCES mtgonline_users(id) ON DELETE SET NULL,
action_type VARCHAR(50) NOT NULL,
target_user_id INTEGER REFERENCES mtgonline_users(id) ON DELETE SET NULL,
details TEXT,
ip_address VARCHAR(45),
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
]
for table_sql in tables:
await session.execute(text(table_sql))
# Create indexes
indexes = [
"CREATE INDEX IF NOT EXISTS idx_decks_owner ON mtgonline_decklist_files(owner_id);",
"CREATE INDEX IF NOT EXISTS idx_decks_folder ON mtgonline_decklist_files(folder_id);",
"CREATE INDEX IF NOT EXISTS idx_bans_active ON mtgonline_bans(active);",
"CREATE INDEX IF NOT EXISTS idx_log_timestamp ON mtgonline_log(timestamp);",
]
for idx_sql in indexes:
await session.execute(text(idx_sql))
print("✓ All mtgonline tables created")
await engine.dispose()
async def setup_mtg_data_database():
"""Create tables for the mtgdata database."""
settings = get_settings()
engine = create_async_engine(settings.MTG_DATABASE_URL)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# Create tables
tables = [
"""
CREATE TABLE IF NOT EXISTS mtg_sets (
id SERIAL PRIMARY KEY,
code VARCHAR(10) UNIQUE NOT NULL,
name VARCHAR(255),
type VARCHAR(100),
release_date DATE,
base_set_size INTEGER,
total_size INTEGER,
is_foil_only BOOLEAN,
is_non_foil_only BOOLEAN,
digital BOOLEAN,
icon_svg_url TEXT,
parent_code VARCHAR(10),
mtgo_code VARCHAR(10),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS mtg_cards (
id SERIAL PRIMARY KEY,
set_id INTEGER REFERENCES mtg_sets(id) ON DELETE CASCADE,
name VARCHAR(255),
mana_cost VARCHAR(255),
type_line VARCHAR(255),
oracle_text TEXT,
power VARCHAR(50),
toughness VARCHAR(50),
rarity VARCHAR(50),
layout VARCHAR(50),
artist VARCHAR(255),
flavor_text TEXT,
numbers VARCHAR(100),
identifiers TEXT,
images TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""",
]
for table_sql in tables:
await session.execute(text(table_sql))
# Create indexes
indexes = [
"CREATE INDEX IF NOT EXISTS idx_mtg_cards_name ON mtg_cards(name);",
"CREATE INDEX IF NOT EXISTS idx_mtg_cards_type ON mtg_cards(type_line);",
"CREATE INDEX IF NOT EXISTS idx_mtg_cards_rarity ON mtg_cards(rarity);",
"CREATE INDEX IF NOT EXISTS idx_mtg_cards_set_id ON mtg_cards(set_id);",
]
for idx_sql in indexes:
await session.execute(text(idx_sql))
print("✓ All mtgdata tables created")
await engine.dispose()
async def main():
"""Main initialization function."""
print("MTG Online Database Initialization")
print("=" * 50)
# Setup mtgonline database
print("\nSetting up mtgonline database...")
await setup_mtg_online_database()
# Setup mtgdata database
print("\nSetting up mtgdata database...")
await setup_mtg_data_database()
print("\n✓ Database initialization complete!")
if __name__ == "__main__":
asyncio.run(main())
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""Test MTGJSON API endpoints and download JSON files."""
import asyncio
import aiohttp
from pathlib import Path
DATA_DIR = Path("/app/data/mtgjson")
DATA_DIR.mkdir(parents=True, exist_ok=True)
# MTGJSON API endpoints for .json files
MTGJSON_BASE_URL = "https://mtgjson.com/api/v5"
JSON_FILES = {
"AllPrintings.json": MTGJSON_BASE_URL + "/AllPrintings.json",
"AllSetFiles.json": MTGJSON_BASE_URL + "/AllSetFiles.json",
"AllIdentifiers.json": MTGJSON_BASE_URL + "/AllIdentifiers.json",
"CardTypes.json": MTGJSON_BASE_URL + "/CardTypes.json",
"DeckList.json": MTGJSON_BASE_URL + "/DeckList.json",
"Keywords.json": MTGJSON_BASE_URL + "/Keywords.json",
"SetList.json": MTGJSON_BASE_URL + "/SetList.json",
}
async def test_download_file(session, url, filename):
"""Test downloading a single JSON file."""
dest_path = DATA_DIR / filename
print(f"Testing {filename}...")
try:
async with session.get(url) as response:
if response.status == 200:
content = await response.read()
print(f" ✓ Status: {response.status}")
print(f" ✓ Size: {len(content) / (1024*1024):.2f} MB")
print(f" ✓ Content-Type: {response.content_type}")
# Write to file
with open(dest_path, 'wb') as f:
f.write(content)
print(f" ✓ Saved to {dest_path}")
# Test JSON parsing
import json
with open(dest_path, 'r', encoding='utf-8') as f:
data = json.load(f)
print(f" ✓ Valid JSON: {type(data).__name__}")
return True
else:
print(f" ✗ Status: {response.status}")
return False
except Exception as e:
print(f" ✗ Error: {e}")
return False
async def main():
"""Test all MTGJSON JSON endpoints."""
print("Testing MTGJSON JSON API endpoints...\n")
async with aiohttp.ClientSession() as session:
results = []
for filename, url in JSON_FILES.items():
success = await test_download_file(session, url, filename)
results.append((filename, success))
print()
print("\n=== Results ===")
for filename, success in results:
status = "" if success else ""
print(f"{status} {filename}")
if __name__ == "__main__":
asyncio.run(main())
-358
View File
@@ -1,358 +0,0 @@
#!/usr/bin/env python3
"""
Backend System Test Script
Tests all backend components:
- Database connections (MTG Online + MTG)
- Redis connection and caching
- Card database queries
- API endpoints
- Health checks
"""
import asyncio
import sys
from datetime import datetime
# Add backend to path
sys.path.insert(0, '/home/wall-o/projects/mtgonline/backend')
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy import text
# Database URLs
COCKATRICE_DB = "postgresql+asyncpg://mtgonline:mtgonline_pass@localhost:5432/mtgonline"
MTG_DB = "postgresql+asyncpg://mtgonline:mtgonline_pass@localhost:5432/mtgdata"
REDIS_URL = "redis://localhost:6379/0"
# Test URLs - detect environment
import socket
try:
# Try Docker network first
socket.getaddrinfo('mtg_backend', 8000)
API_BASE = 'http://mtg_backend:8000'
except:
# Fall back to host port
API_BASE = 'http://localhost:8001'
print(f"Using API base: {API_BASE}")
results = {
"tests_run": 0,
"tests_passed": 0,
"tests_failed": 0,
"failures": [],
"timestamp": datetime.now().isoformat()
}
async def test_connection(db_url: str, name: str) -> bool:
"""Test database connection."""
results["tests_run"] += 1
try:
engine = create_async_engine(db_url, echo=False)
async with engine.connect() as conn:
result = await conn.execute(text("SELECT 1"))
await engine.dispose()
results["tests_passed"] += 1
print(f"{name} connection: OK")
return True
except Exception as e:
results["tests_failed"] += 1
results["failures"].append(f"{name} connection: {str(e)}")
print(f"{name} connection: FAILED - {str(e)}")
return False
async def test_redis() -> bool:
"""Test Redis connection."""
results["tests_run"] += 1
try:
import redis.asyncio as aioredis
client = aioredis.from_url(REDIS_URL, decode_responses=True)
await client.ping()
await client.close()
results["tests_passed"] += 1
print("✓ Redis connection: OK")
return True
except Exception as e:
results["tests_failed"] += 1
results["failures"].append(f"Redis connection: {str(e)}")
print(f"✗ Redis connection: FAILED - {str(e)}")
return False
async def test_cache_operations() -> bool:
"""Test Redis cache operations."""
results["tests_run"] += 1
try:
import redis.asyncio as aioredis
client = aioredis.from_url(REDIS_URL, decode_responses=True)
# Test set/get
await client.set("test_key", "test_value", ex=60)
value = await client.get("test_key")
assert value == "test_value", f"Expected 'test_value', got '{value}'"
# Test delete
await client.delete("test_key")
value = await client.get("test_key")
assert value is None, f"Expected None after delete, got '{value}'"
await client.close()
results["tests_passed"] += 1
print("✓ Redis cache operations: OK")
return True
except Exception as e:
results["tests_failed"] += 1
results["failures"].append(f"Redis cache operations: {str(e)}")
print(f"✗ Redis cache operations: FAILED - {str(e)}")
return False
async def test_mtgonline_tables() -> bool:
"""Test MTG Online database tables."""
results["tests_run"] += 1
try:
engine = create_async_engine(COCKATRICE_DB, echo=False)
async with engine.connect() as conn:
# Test users table
result = await conn.execute(text("SELECT COUNT(*) FROM mtgonline_users"))
user_count = result.scalar()
# Test decks table
result = await conn.execute(text("SELECT COUNT(*) FROM mtgonline_decklist_files"))
deck_count = result.scalar()
await engine.dispose()
results["tests_passed"] += 1
print(f"✓ MTG Online tables: OK (users: {user_count}, decks: {deck_count})")
return True
except Exception as e:
results["tests_failed"] += 1
results["failures"].append(f"MTG Online tables: {str(e)}")
print(f"✗ MTG Online tables: FAILED - {str(e)}")
return False
async def test_mtg_tables() -> bool:
"""Test MTG database tables."""
results["tests_run"] += 1
try:
engine = create_async_engine(MTG_DB, echo=False)
async with engine.connect() as conn:
# Test mtg_sets table
result = await conn.execute(text("SELECT COUNT(*) FROM mtg_sets"))
set_count = result.scalar()
# Test mtg_cards table
result = await conn.execute(text("SELECT COUNT(*) FROM mtg_cards"))
card_count = result.scalar()
await engine.dispose()
results["tests_passed"] += 1
print(f"✓ MTG tables: OK (sets: {set_count}, cards: {card_count})")
return True
except Exception as e:
results["tests_failed"] += 1
results["failures"].append(f"MTG tables: {str(e)}")
print(f"✗ MTG tables: FAILED - {str(e)}")
return False
async def test_mtg_card_query() -> bool:
"""Test MTG card query functionality."""
results["tests_run"] += 1
try:
engine = create_async_engine(MTG_DB, echo=False)
async with engine.connect() as conn:
# Search for a common card
result = await conn.execute(
text("SELECT name, type_line, rarity FROM mtg_cards WHERE name ILIKE '%Black Lotus%' LIMIT 1")
)
row = result.fetchone()
await engine.dispose()
if row:
results["tests_passed"] += 1
print(f"✓ MTG card query: OK - Found '{row[0]}' ({row[1]}, {row[2]})")
return True
else:
results["tests_failed"] += 1
results["failures"].append("MTG card query: No results found")
print("✗ MTG card query: FAILED - No results found")
return False
except Exception as e:
results["tests_failed"] += 1
results["failures"].append(f"MTG card query: {str(e)}")
print(f"✗ MTG card query: FAILED - {str(e)}")
return False
async def test_health_endpoint() -> bool:
"""Test backend health endpoint."""
results["tests_run"] += 1
try:
import requests
response = requests.get("http://localhost:8001/health", timeout=5)
if response.status_code == 200:
results["tests_passed"] += 1
print(f"✓ Health endpoint: OK ({response.json()})")
return True
else:
results["tests_failed"] += 1
results["failures"].append(f"Health endpoint: Status {response.status_code}")
print(f"✗ Health endpoint: FAILED - Status {response.status_code}")
return False
except Exception as e:
results["tests_failed"] += 1
results["failures"].append(f"Health endpoint: {str(e)}")
print(f"✗ Health endpoint: FAILED - {str(e)}")
return False
async def test_card_search_endpoint() -> bool:
"""Test card search API endpoint."""
results["tests_run"] += 1
try:
import requests
response = requests.get(
"http://localhost:8001/api/v1/mtg/cards/search",
params={"q": "Lightning Bolt", "limit": 5},
timeout=10
)
if response.status_code == 200:
data = response.json()
results["tests_passed"] += 1
print(f"✓ Card search endpoint: OK - Found {len(data.get('results', {}).get('results', []))} cards")
return True
else:
results["tests_failed"] += 1
results["failures"].append(f"Card search endpoint: Status {response.status_code}")
print(f"✗ Card search endpoint: FAILED - Status {response.status_code}")
return False
except Exception as e:
results["tests_failed"] += 1
results["failures"].append(f"Card search endpoint: {str(e)}")
print(f"✗ Card search endpoint: FAILED - {str(e)}")
return False
async def test_statistics_endpoint() -> bool:
"""Test card statistics API endpoint."""
results["tests_run"] += 1
try:
import requests
response = requests.get(
"http://localhost:8001/api/v1/mtg/cards/statistics",
timeout=10
)
if response.status_code == 200:
data = response.json()
results["tests_passed"] += 1
stats = data.get("results", {}).get("results", {})
print(f"✓ Statistics endpoint: OK - {stats.get('total_cards', 0)} cards, {stats.get('total_sets', 0)} sets")
return True
else:
results["tests_failed"] += 1
results["failures"].append(f"Statistics endpoint: Status {response.status_code}")
print(f"✗ Statistics endpoint: FAILED - Status {response.status_code}")
return False
except Exception as e:
results["tests_failed"] += 1
results["failures"].append(f"Statistics endpoint: {str(e)}")
print(f"✗ Statistics endpoint: FAILED - {str(e)}")
return False
async def test_auth_endpoint() -> bool:
"""Test authentication endpoint."""
results["tests_run"] += 1
try:
import requests
response = requests.post(
"http://localhost:8001/api/v1/auth/register",
json={
"username": "test_user",
"email": "test@example.com",
"password": "TestPass123!",
"confirm_password": "TestPass123!"
},
timeout=10
)
if response.status_code in [200, 409]: # 409 means user already exists
results["tests_passed"] += 1
print(f"✓ Auth endpoint: OK - Status {response.status_code}")
return True
else:
results["tests_failed"] += 1
results["failures"].append(f"Auth endpoint: Status {response.status_code}")
print(f"✗ Auth endpoint: FAILED - Status {response.status_code}")
return False
except Exception as e:
results["tests_failed"] += 1
results["failures"].append(f"Auth endpoint: {str(e)}")
print(f"✗ Auth endpoint: FAILED - {str(e)}")
return False
async def run_all_tests():
"""Run all system tests."""
print("=" * 60)
print("BACKEND SYSTEM TEST")
print("=" * 60)
print(f"Started at: {datetime.now().isoformat()}")
print("=" * 60)
print()
# Database connections
print("Testing Database Connections:")
print("-" * 60)
await test_connection(COCKATRICE_DB, "MTG Online PostgreSQL")
await test_connection(MTG_DB, "MTG PostgreSQL")
await test_redis()
print()
# Cache operations
print("Testing Cache Operations:")
print("-" * 60)
await test_cache_operations()
print()
# Database tables
print("Testing Database Tables:")
print("-" * 60)
await test_mtgonline_tables()
await test_mtg_tables()
await test_mtg_card_query()
print()
# API endpoints
print("Testing API Endpoints:")
print("-" * 60)
await test_health_endpoint()
await test_card_search_endpoint()
await test_statistics_endpoint()
await test_auth_endpoint()
print()
# Summary
print("=" * 60)
print("TEST SUMMARY")
print("=" * 60)
print(f"Tests Run: {results['tests_run']}")
print(f"Tests Passed: {results['tests_passed']}")
print(f"Tests Failed: {results['tests_failed']}")
print(f"Success Rate: {(results['tests_passed'] / results['tests_run'] * 100):.1f}%")
print("=" * 60)
if results["failures"]:
print("\nFailures:")
for failure in results["failures"]:
print(f"{failure}")
print(f"\nCompleted at: {datetime.now().isoformat()}")
print("=" * 60)
return results["tests_failed"] == 0
if __name__ == "__main__":
success = asyncio.run(run_all_tests())
sys.exit(0 if success else 1)
+76 -35
View File
@@ -1,48 +1,89 @@
{ {
"task_description": "Complete documentation and cleanup of MTG Online Backend project", "project_summary": "MTG Online Backend — a Python FastAPI application that processes Magic: The Gathering card data from MTGJSON v5 and stores it in PostgreSQL, with Redis for caching. Exposes REST endpoints for card data, user authentication, deck management, and game state. Targets a web-based MTG card browsing and deck-building platform.",
"current_step": "All tasks completed: documentation, commit/push to Gitea, Docker cleanup", "roadmap": [
"files_created": [ {
"/home/wall-o/projects/mtgonline/README.md", "phase": "Phase 1: Foundation & Data Pipeline",
"/home/wall-o/projects/mtgonline/backend/README.md" "status": "completed",
], "description": "Backend scaffolding, dual PostgreSQL setup, Redis, Docker Compose, MTGJSON download and upsert pipeline.",
"files_modified": [ "key_deliverables": ["FastAPI app running", "Dual Postgres (app + MTG data)", "Redis cache", "MTGJSON v5 pipeline", "22k+ cards loaded"]
"/home/wall-o/projects/mtgonline/README.md", },
"/home/wall-o/projects/mtgonline/state.json" {
], "phase": "Phase 2: Core API Endpoints",
"decisions": [ "status": "in_progress",
"Updated root README with current architecture (dual PostgreSQL, Redis, MTGJSON pipeline)", "description": "REST endpoints for card search, deck management, user auth, and game state.",
"Created comprehensive backend README with architecture, database setup, and troubleshooting", "key_deliverables": ["Card search API", "Deck CRUD", "Auth system", "Health check", "MTGJSON refresh endpoint"]
"Hardcoded environment variables in docker-compose.dev.yml to prevent connection issues", },
"Backend successfully connects to mtgdata:5432/mtgdata (not localhost)", {
"All Docker resources cleaned up: containers, images, volumes, networks" "phase": "Phase 3: Frontend",
], "status": "pending",
"next_steps": [], "description": "Web interface for deck building and card browsing.",
"blockers": [], "key_deliverables": ["React/Next.js frontend", "API integration", "Responsive UI", "Docker Compose integration"]
"commit_hash": "46abfe5", },
"timestamp": "2026-07-21T03:56:00Z" {
"phase": "Phase 4: Advanced Features",
"status": "pending",
"description": "Game server integration, multiplayer support, card image serving, performance optimization.",
"key_deliverables": ["Game server", "Multiplayer", "Card images", "Caching optimization"]
} }
"task_description": "Complete documentation and cleanup of MTG Online Backend project", ],
"current_step": "Documentation complete, committing and pushing to Gitea, then cleaning up Docker", "tech_stack": ["Python 3.12", "FastAPI", "SQLAlchemy (async)", "PostgreSQL x2", "Redis", "Docker Compose", "MTGJSON v5"],
"architectural_notes": "Dual PostgreSQL (mtgonline for app data, mtgdata for MTG card data), Redis caching layer, MTGJSON v5 data pipeline auto-downloads on startup, REST API with Swagger docs at /docs. Backend exposed on port 5555. C++ game server directory exists but not yet integrated.",
"task_description": "Clean up backend folder - remove obsolete scripts, test files, and unused modules",
"current_step": "Backend cleanup completed: removed obsolete scripts, test files, __pycache__, venv, and unused modules",
"files_created": [ "files_created": [
"/home/wall-o/projects/mtgonline/README.md", "/home/wall-o/projects/mtgonline/README.md",
"/home/wall-o/projects/mtgonline/backend/README.md" "/home/wall-o/projects/mtgonline/backend/README.md"
], ],
"files_modified": [ "files_removed": [
"/home/wall-o/projects/mtgonline/README.md" "BACKEND_TESTING_SUMMARY.md",
"CHAT_PROMPT_TEST.md",
"CONTINUATION_PROMPT.md",
"PORTED_STATE.md",
"SPEC_synergy-mapping-engine.md",
"STATE.md",
"SUPPORTED_FILE_TYPES.md",
"test.db",
"test_download.py",
"test_system.py",
"setup_db.py",
".env.local",
"state.json (backend)",
"card_interaction_rule_engine.py",
"card_profile_extractor.py",
"create_card_interaction_graph.py",
"interaction_determinator.py",
"interaction_pipeline.py",
"interaction_recommender.py",
"interaction_schema.py",
"recommendation_engine.py",
"migrate_complete.py",
"migrate_schema.py",
"test_interaction_determinator.py",
"check_mtgjson_full.py",
"check_mtgjson_status.py",
"verify_integration.py",
"verify_mtgjson_data.py",
"sanity_check_mtgjson.py",
"investigate_sets.py",
"inspect_db.py",
"code_review.md",
"monitor/mtg_monitor.py"
], ],
"decisions": [ "decisions": [
"Updated root README to reflect current architecture (dual PostgreSQL, Redis, MTGJSON pipeline)", "Removed obsolete scripts that were not imported or used by the application",
"Created comprehensive backend README with architecture, database setup, MTGJSON pipeline, and troubleshooting", "Removed test.db and test-related scripts that were development artifacts",
"Hardcoded environment variables in docker-compose.dev.yml to prevent connection issues", "Removed __pycache__ directories to clean up bytecode cache",
"Backend successfully connects to mtgdata:5432/mtgdata (not localhost)" "Removed venv directory (created fresh in Docker build)",
"Removed monitor/mtg_monitor.py (unused monitoring script)",
"Removed .env.local (sensitive environment file)",
"Backend scripts/ directory now contains only essential data loading and maintenance scripts"
], ],
"next_steps": [ "next_steps": [
"Commit and push changes to Gitea repository", "Commit cleanup changes to Gitea",
"Stop and destroy all Docker containers", "Resume development on Phase 2 API endpoints",
"Clear Docker build cache", "Or begin Phase 3 frontend planning"
"Run docker system prune to remove all traces"
], ],
"blockers": [], "blockers": [],
"commit_hash": "", "commit_hash": "46abfe5",
"timestamp": "2026-07-21T03:47:00Z" "timestamp": "2026-07-22T02:59:00Z"
} }