v23: React + TypeScript SPA migration
Complete frontend rewrite from vanilla HTML/CSS/JS to React 19 + TypeScript + Tailwind CSS v4. Frontend (frontend/commander-ai-lab-ui/): - Vite + React 19 + TypeScript + Tailwind CSS v4 + React Router - 7 pages: BatchSim, Collection, Decks, AutoGen, Simulator, Coach, Training - Reusable components: Spinner, StatusBadge, CardImage, ManaSymbols, EmptyState - Full typed API client layer (8 modules matching all backend endpoints) - Recharts for mana curves, win rate charts, progress bars - Lucide icons throughout - Custom dark theme with MTG mana color tokens - Code splitting (recharts + react in separate chunks) Backend (lab_api.py): - SPA-aware static file serving with catch-all for client-side routing - Fallback to legacy ui/ folder if React dist not built - Updated startup log with SPA route list Build system: - .gitignore added (was missing - removes pyc, db, node_modules, etc.) - build-full-installer-v2.py: v23 with Node.js check + React build step - Vite build config with manual chunks for optimal loading Also includes: V3 deck generator fixes, coach improvements, DeepSeek sim modules
This commit is contained in:
1 parent
e6b5e0cedb
commit
a5fe8cb3fa
376 files changed
+18367
-22656
No files matched your search
@@ -1,2 +0,0 @@
|
|||||||
# Auto detect text files and perform LF normalization
|
|
||||||
* text=auto
|
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# Build artifacts
|
||||||
|
target/
|
||||||
|
dist/
|
||||||
|
dependency-reduced-pom.xml
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Data & runtime
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
results/
|
||||||
|
imported-decks/
|
||||||
|
deck-reports/
|
||||||
|
coach-sessions/
|
||||||
|
embeddings/
|
||||||
|
logs/
|
||||||
|
checkpoints/
|
||||||
|
data/mtg-embeddings.npz
|
||||||
|
data/mtg-embeddings.parquet
|
||||||
|
card_names_cache.json
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Installer (generated, too large for git)
|
||||||
|
install-commander-ai-lab.ps1
|
||||||
@@ -1 +0,0 @@
|
|||||||
["\"Ach! Hans, Run!\"", "\"Brims\" Barone, Midway Mobster", "\"Lifetime\" Pass Holder", "\"Name Sticker\" Goblin", "\"Rumors of My Death . . .\"", "+2 Mace", "17-Year Cicadas", "1996 World Champion", "A Display of My Dark Power", "A Girl and Her Dogs", "A Golden Opportunity", "A Good Day to Pie", "A Good Thing", "A Killer Among Us", "A Little Chat", "A Mysterious Creature", "A Premonition of Your Demise", "A Real Handful", "A Realm Reborn", "A Reckoning Approaches", "A Tale for the Ages", "A-Acererak the Archlich", "A-Akki Ronin", "A-Alrund's Epiphany", "A-Alrund, God of the Cosmos // A-Hakka, Whispering Raven", "A-Ancestral Katana", "A-Ardent Dustspeaker", "A-Armory Veteran", "A-Asari Captain", "A-Baleful Beholder", "A-Base Camp", "A-Binding Geist // A-Spectral Binding", "A-Blessed Hippogriff // A-Tyr's Blessing", "A-Blood Artist", "A-Bretagard Stronghold", "A-Briar Hydra", "A-Brine Comber // A-Brinebound Gift", "A-Bruenor Battlehammer", "A-Buy Your Silence", "A-Cabaretti Charm", "A-Canopy Tactician", "A-Capenna Express", "A-Carnelian Orb of Dragonkind", "A-Case the Joint", "A-Cauldron Familiar", "A-Celebrity Fencer", "A-Celestial Regulator", "A-Circle of the Land Druid", "A-Circuit Mender", "A-Civil Servant", "A-Cloister Gargoyle", "A-Cobbled Lancer", "A-Cori-Steel Cutter", "A-Cosmos Charger", "A-Cosmos Elixir", "A-Dawnbringer Cleric", "A-Deal Gone Bad", "A-Death-Priest of Myrkul", "A-Demilich", "A-Demon's Due", "A-Devoted Grafkeeper // A-Departed Soulkeeper", "A-Dokuchi Silencer", "A-Dorothea, Vengeful Victim // A-Dorothea's Retribution", "A-Dragon's Rage Channeler", "A-Dragonborn Looter", "A-Dreamshackle Geist", "A-Druid Class", "A-Druidic Ritual", "A-Dueling Coach", "A-Dungeon Descent", "A-Dwarfhold Champion", "A-Earthquake Dragon", "A-Eiganjo Exemplar", "A-Elderfang Ritualist", "A-Elderleaf Mentor", "A-Ellywick Tumblestrum", "A-Elven Bow", "A-Emerald Dragon // A-Dissonant Wave", "A-Excavation Explosion", "A-Exhibition Magician", "A-Eyes of the Beholder", "A-Falcon Abomination", "A-Fall of the Impostor", "A-Fates' Reversal", "A-Find the Path", "A-Forge Boss", "A-Futurist Operative", "A-Galvanic Discharge", "A-Geological Appraiser", "A-Geology Enthusiast", "A-Glamorous Outlaw", "A-Glittermonger", "A-Gnarlid Colony", "A-Goggles of Night", "A-Goma Fada Vanguard", "A-Graveyard Shift", "A-Guide of Souls", "A-Guildsworn Prowler", "A-Gutter Skulker // A-Gutter Shortcut", "A-Hagra Constrictor", "A-Hall of Tagsin", "A-Harald Unites the Elves", "A-Harald, King of Skemfar", "A-Haywire Mite", "A-Heartfire Hero", "A-High-Rise Sawjack", "A-Hobbling Zombie", "A-Imperial Subduer", "A-Incriminate", "A-Iridescent Hornbeetle", "A-Jackhammer", "A-Jade Orb of Dragonkind", "A-Kargan Intimidator", "A-Kargan Warleader", "A-Karn, Living Legacy", "A-Kenku Artificer", "A-Knockout Blow", "A-Kona, Rescue Beastie", "A-Krydle of Baldur's Gate", "A-Lantern Bearer // A-Lanterns' Lift", "A-Lantern of Revealing", "A-Lapis Orb of Dragonkind", "A-Leyline of Resonance", "A-Lier, Disciple of the Drowned", "A-Llanowar Greenwidow", "A-Llanowar Loamspeaker", "A-Maelstrom Muse", "A-Manticore", "A-Masked Bandits", "A-Master of Winds", "A-Mentor's Guidance", "A-Meria's Outrider", "A-Metropolis Angel", "A-Midnight Assassin", "A-Mightstone's Animation", "A-Minsc & Boo, Timeless Heroes", "A-Mischievous Catgeist // A-Catlike Curiosity", "A-Mishra, Excavation Prodigy", "A-Moon-Circuit Hacker", "A-Moss-Pit Skeleton", "A-Most Wanted", "A-Mr. Orfeo, the Boulder", "A-Nadu, Winged Wisdom", "A-Nael, Avizoa Aeronaut", "A-Nahiri, Heir of the Ancients", "A-Narfi, Betrayer King", "A-Nashi, Moon Sage's Scion", "A-Navigation Orb", "A-Nezumi Prowler", "A-Ocelot Pride", "A-Ochre Jelly", "A-Ominous Parcel", "A-Omnath, Locus of Creation", "A-Oran-Rief Ooze", "A-Orcish Bowmasters", "A-Paragon of Modernity", "A-Patrician Geist", "A-Peerless Samurai", "A-Phantom Carriage", "A-Phylath, World Sculptor", "A-Plate Armor", "A-Precipitous Drop", "A-Prosperous Thief", "A-Pseudodragon Familiar", "A-Psionic Snoop", "A-Public Enemy", "A-Pyre-Sledge Arsonist", "A-Queza, Augur of Agonies", "A-Radha's Firebrand", "A-Radha, Coalition Warlord", "A-Raiyuu, Storm's Edge", "A-Rakish Revelers", "A-Ready to Rumble", "A-Return Upon the Tide", "A-Revel Ruiner", "A-Riveteers Initiate", "A-Rockslide Sorcerer", "A-Rowan, Scholar of Sparks // A-Will, Scholar of Frost", "A-Rulik Mons, Warren Chief", "A-Saheeli, Filigree Master", "A-Satoru Umezawa", "A-Scout the Wilderness", "A-Security Rhox", "A-Sepulcher Ghoul", "A-Sewer Crocodile", "A-Shattered Seraph", "A-Shessra, Death's Whisper", "A-Shipwreck Sifters", "A-Sigardian Paladin", "A-Sigil of Myrkul", "A-Silver-Fur Master", "A-Sizzling Soloist", "A-Skemfar Avenger", "A-Skemfar Elderhall", "A-Skull Skaab", "A-Skyclave Shadowcat", "A-Social Climber", "A-Sorcerer Class", "A-Sorin, Imperious Bloodlord", "A-Soul of Windgrace", "A-Spara's Adjudicators", "A-Speakeasy Server", "A-Spell Satchel", "A-Split the Spoils", "A-Splitting the Powerstone", "A-SproLine truncated
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{
|
|
||||||
"sessionId": "sess-20260309-192805-5109b9",
|
|
||||||
"deckId": "Angels",
|
|
||||||
"timestamp": "2026-03-09T19:28:05.274335+00:00",
|
|
||||||
"summary": "The Angels Commander deck has a mediocre overall win rate of 39.5% with an average game length of 11.8 turns. The matchup data reveals struggles against Edgar Markov and Ashling 2.0. There are no clear underperforming cards, but the lack of lands in the deck structure is concerning.",
|
|
||||||
"suggestedCuts": [
|
|
||||||
{
|
|
||||||
"cardName": "a card",
|
|
||||||
"reason": "This card has a negative impact score and was not cast at all during simulations.",
|
|
||||||
"replacementOptions": [
|
|
||||||
"Ajani's Pridemate",
|
|
||||||
"Serra Ascendant"
|
|
||||||
],
|
|
||||||
"currentImpactScore": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"suggestedAdds": [
|
|
||||||
{
|
|
||||||
"cardName": "Llanowar Wastes",
|
|
||||||
"role": "ramp",
|
|
||||||
"reason": "The deck lacks lands, which is a critical component for mana development and consistency.",
|
|
||||||
"synergyWith": [
|
|
||||||
"a card",
|
|
||||||
"another card"
|
|
||||||
],
|
|
||||||
"estimatedManaValue": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cardName": "Rampant Growth",
|
|
||||||
"role": "draw",
|
|
||||||
"reason": "This card can help the deck find more cards to play, potentially improving its overall win rate.",
|
|
||||||
"synergyWith": [
|
|
||||||
"Ajani's Pridemate",
|
|
||||||
"Serra Ascendant"
|
|
||||||
],
|
|
||||||
"estimatedManaValue": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cardName": "Path to Exile",
|
|
||||||
"role": "removal",
|
|
||||||
"reason": "The deck may struggle against certain opponents, and this card can provide a necessary removal spell.",
|
|
||||||
"synergyWith": [
|
|
||||||
"Ajani's Pridemate",
|
|
||||||
"Serra Ascendant"
|
|
||||||
],
|
|
||||||
"estimatedManaValue": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"heuristicHints": [
|
|
||||||
"Consider adding more lands to improve mana development and consistency."
|
|
||||||
],
|
|
||||||
"manaBaseAdvice": "Add Llanowar Wastes, which provides a crucial land and can help the deck develop its mana base.",
|
|
||||||
"rawTextExplanation": "The Angels Commander deck appears to be struggling due to its lack of lands and inconsistent card draw. By adding more lands and ramp spells, the deck can improve its overall win rate and become more competitive against top opponents. The suggested cuts aim to remove underperforming cards that were not cast during simulations, while the suggested adds focus on improving mana development, card draw, and removal. These changes should help the deck reach a more balanced state and potentially increase its overall win rate.",
|
|
||||||
"modelUsed": "meta-llama-3.1-8b-instruct",
|
|
||||||
"promptTokens": 642,
|
|
||||||
"completionTokens": 495,
|
|
||||||
"goals": {
|
|
||||||
"targetPowerLevel": 7,
|
|
||||||
"metaFocus": null,
|
|
||||||
"budget": null,
|
|
||||||
"focusAreas": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
{
|
|
||||||
"sessionId": "sess-20260309-192859-4b9fb6",
|
|
||||||
"deckId": "Toph",
|
|
||||||
"timestamp": "2026-03-09T19:28:59.869421+00:00",
|
|
||||||
"summary": "The deck struggles with a lack of information about its color identity and mana base, but its performance is consistent across two different matchups.",
|
|
||||||
"suggestedCuts": [
|
|
||||||
{
|
|
||||||
"cardName": "a card",
|
|
||||||
"reason": "This card has no impact on the game due to never being cast or drawn.",
|
|
||||||
"replacementOptions": [
|
|
||||||
"Azcanta, the Sunken Ruin",
|
|
||||||
"Atraxa's Scepter"
|
|
||||||
],
|
|
||||||
"currentImpactScore": 0.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cardName": "three cards",
|
|
||||||
"reason": "These cards also have no impact on the game for the same reason as 'a card'.",
|
|
||||||
"replacementOptions": [
|
|
||||||
"Karn, the Great Creator",
|
|
||||||
"Liliana, Dreadhorde General"
|
|
||||||
],
|
|
||||||
"currentImpactScore": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"suggestedAdds": [
|
|
||||||
{
|
|
||||||
"cardName": "Wrenn and Six",
|
|
||||||
"role": "ramp|draw|utility",
|
|
||||||
"reason": "This card can help to ramp up mana and draw cards, making it a versatile addition.",
|
|
||||||
"synergyWith": [
|
|
||||||
"Atraxa",
|
|
||||||
"Zurgo Bellstriker"
|
|
||||||
],
|
|
||||||
"estimatedManaValue": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cardName": "Azcanta, the Sunken Ruin",
|
|
||||||
"role": "utility|draw",
|
|
||||||
"reason": "This card can draw cards and provide a utility effect, helping to improve overall deck performance.",
|
|
||||||
"synergyWith": [
|
|
||||||
"Atraxa",
|
|
||||||
"Zurgo Bellstriker"
|
|
||||||
],
|
|
||||||
"estimatedManaValue": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"heuristicHints": [
|
|
||||||
"Consider adding more ramp spells to help accelerate mana development."
|
|
||||||
],
|
|
||||||
"manaBaseAdvice": null,
|
|
||||||
"rawTextExplanation": "Given the lack of information about the deck's color identity and mana base, it is difficult to provide targeted advice. However, the consistent performance across two different matchups suggests that the deck may benefit from more aggressive ramp spells and versatile utility cards like Wrenn and Six or Azcanta, the Sunken Ruin.",
|
|
||||||
"modelUsed": "meta-llama-3.1-8b-instruct",
|
|
||||||
"promptTokens": 567,
|
|
||||||
"completionTokens": 417,
|
|
||||||
"goals": {
|
|
||||||
"targetPowerLevel": 7,
|
|
||||||
"metaFocus": null,
|
|
||||||
"budget": null,
|
|
||||||
"focusAreas": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"sessionId": "sess-20260309-192954-9c7251",
|
|
||||||
"deckId": "Edgar Markov",
|
|
||||||
"timestamp": "2026-03-09T19:29:54.635032+00:00",
|
|
||||||
"summary": "The deck's win rate is mediocre at 48.3%, with inconsistent performance across different match-ups. There are no clear underperforming cards, but the lack of matchup data against certain archetypes suggests room for improvement.",
|
|
||||||
"suggestedCuts": [
|
|
||||||
{
|
|
||||||
"cardName": "Card to remove not available due to missing deck structure data",
|
|
||||||
"reason": "No clear underperformers identified, likely due to inadequate simulation sample size or data quality issues",
|
|
||||||
"replacementOptions": [
|
|
||||||
"Rampant Growth",
|
|
||||||
"Gilt-Leaf Winnower"
|
|
||||||
],
|
|
||||||
"currentImpactScore": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"suggestedAdds": [
|
|
||||||
{
|
|
||||||
"cardName": "Cultivate",
|
|
||||||
"role": "ramp",
|
|
||||||
"reason": "A versatile ramp spell that provides card advantage and can help the deck develop its mana base more efficiently",
|
|
||||||
"synergyWith": [
|
|
||||||
"Edgar Markov",
|
|
||||||
"Gleeful Wrangler"
|
|
||||||
],
|
|
||||||
"estimatedManaValue": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cardName": "Serum Apparatus",
|
|
||||||
"role": "removal",
|
|
||||||
"reason": "A cheap, efficient removal spell that can help the deck deal with problematic threats more effectively",
|
|
||||||
"synergyWith": [
|
|
||||||
"Cultivate",
|
|
||||||
"Rampant Growth"
|
|
||||||
],
|
|
||||||
"estimatedManaValue": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"heuristicHints": [
|
|
||||||
"Focus on developing a robust mana base and ramp package to improve overall consistency."
|
|
||||||
],
|
|
||||||
"manaBaseAdvice": null,
|
|
||||||
"rawTextExplanation": "Given the lack of information about the deck's structure and performance, it's difficult to provide more specific advice. However, based on general principles, adding cards like Cultivate and Serum Apparatus can help address potential issues with ramp and removal. These suggestions are aimed at improving the deck's overall consistency and flexibility, but further analysis would be needed to fine-tune the build.",
|
|
||||||
"modelUsed": "meta-llama-3.1-8b-instruct",
|
|
||||||
"promptTokens": 547,
|
|
||||||
"completionTokens": 384,
|
|
||||||
"goals": {
|
|
||||||
"targetPowerLevel": 7,
|
|
||||||
"metaFocus": null,
|
|
||||||
"budget": null,
|
|
||||||
"focusAreas": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"sessionId": "sess-20260309-193052-cb65e3",
|
|
||||||
"deckId": "Angels",
|
|
||||||
"timestamp": "2026-03-09T19:30:52.904749+00:00",
|
|
||||||
"summary": "The Angels Commander deck has a mediocre win rate of 39.5% with an average game length of 11.8 turns. The lack of detailed information on the deck's composition and matchup performance makes it difficult to pinpoint areas for improvement.",
|
|
||||||
"suggestedCuts": [
|
|
||||||
{
|
|
||||||
"cardName": "a card",
|
|
||||||
"reason": "This card has a negative impact score, indicating it may not be contributing positively to the deck's performance. Further analysis is required to determine its actual role and potential removal.",
|
|
||||||
"replacementOptions": [
|
|
||||||
"Better Card 1",
|
|
||||||
"Better Card 2"
|
|
||||||
],
|
|
||||||
"currentImpactScore": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"suggestedAdds": [
|
|
||||||
{
|
|
||||||
"cardName": "Rampant Growth",
|
|
||||||
"role": "ramp",
|
|
||||||
"reason": "The deck's low average game length of 11.8 turns suggests a potential issue with ramping into bigger threats. Adding Rampant Growth could help address this issue.",
|
|
||||||
"synergyWith": [
|
|
||||||
"Gruul Spellbuster",
|
|
||||||
"Tireless Tracker"
|
|
||||||
],
|
|
||||||
"estimatedManaValue": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"heuristicHints": [
|
|
||||||
"Consider adjusting the deck's mana curve to better support its threat density."
|
|
||||||
],
|
|
||||||
"manaBaseAdvice": null,
|
|
||||||
"rawTextExplanation": "Without more information on the deck's composition, it is challenging to provide targeted suggestions. However, based on the low average game length and mediocre win rate, it appears that the deck may be struggling with ramping into bigger threats or generating card advantage. Adding Rampant Growth could help address these issues by providing a consistent source of ramp and allowing the deck to more efficiently develop its board presence.",
|
|
||||||
"modelUsed": "meta-llama-3.1-8b-instruct",
|
|
||||||
"promptTokens": 642,
|
|
||||||
"completionTokens": 344,
|
|
||||||
"goals": {
|
|
||||||
"targetPowerLevel": 7,
|
|
||||||
"metaFocus": null,
|
|
||||||
"budget": null,
|
|
||||||
"focusAreas": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"sessionId": "sess-20260310-013825-7ae232",
|
|
||||||
"deckId": "Angels",
|
|
||||||
"timestamp": "2026-03-10T01:38:25.419019+00:00",
|
|
||||||
"summary": "The Angels deck has a decent win rate overall but struggles significantly against specific decks like those featuring Sonic the Hedgehog and Edgar Markov. While it performs well against Squirrels, it underperforms in matchups with Ashling and Xyris.",
|
|
||||||
"suggestedCuts": [],
|
|
||||||
"suggestedAdds": [
|
|
||||||
{
|
|
||||||
"cardName": "Rampardaptor",
|
|
||||||
"role": "ramp",
|
|
||||||
"reason": "It provides excellent mana acceleration to support the deck's curve and helps against early game threats.",
|
|
||||||
"synergyWith": [
|
|
||||||
"Archdruid of Flourishing",
|
|
||||||
"Oath of the Gatewatch"
|
|
||||||
],
|
|
||||||
"estimatedManaValue": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cardName": "Tainted Strike",
|
|
||||||
"role": "removal",
|
|
||||||
"reason": "It efficiently removes key threat creatures that the deck often faces, improving its consistency.",
|
|
||||||
"synergyWith": [
|
|
||||||
"Angelic Convergence",
|
|
||||||
"Avacyn, the Purifier"
|
|
||||||
],
|
|
||||||
"estimatedManaValue": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"heuristicHints": [
|
|
||||||
"Play out your ramp cards early to set up a strong board position."
|
|
||||||
],
|
|
||||||
"manaBaseAdvice": "Consider including cards like Gaea's Cradle or Wirewood Symbiote for additional mana support.",
|
|
||||||
"rawTextExplanation": "The Angels deck has shown moderate success in most matchups but falters against decks focused on specific synergy or combo strategies, such as those featuring Sonic the Hedgehog. To improve, adding ramp and removal effects can help the deck establish a better board presence early. Cards like Rampardaptor and Tainted Strike can be added to enhance mana acceleration and threat removal respectively, while ensuring that key synergies are utilized effectively.",
|
|
||||||
"modelUsed": "deepseek-r1-distill-llama-8b",
|
|
||||||
"promptTokens": 630,
|
|
||||||
"completionTokens": 927,
|
|
||||||
"goals": {
|
|
||||||
"targetPowerLevel": 7,
|
|
||||||
"metaFocus": null,
|
|
||||||
"budget": null,
|
|
||||||
"focusAreas": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"sessionId": "sess-20260310-064107-02017e",
|
|
||||||
"deckId": "Toph",
|
|
||||||
"timestamp": "2026-03-10T06:41:07.204670+00:00",
|
|
||||||
"summary": "The deck struggles to maintain consistency across matchups, particularly against Xyris and Angels, with an average win rate of 40%. The lack of clear underperforming cards and unknown card types/roles complicates targeted improvements.",
|
|
||||||
"suggestedCuts": [],
|
|
||||||
"suggestedAdds": [
|
|
||||||
{
|
|
||||||
"cardName": "Ancestral Vision",
|
|
||||||
"role": "ramp|draw",
|
|
||||||
"reason": "Enhances mana acceleration and provides consistent card draw, improving overall deck consistency",
|
|
||||||
"synergyWith": [
|
|
||||||
"Gaea's Cradle",
|
|
||||||
"Ezuri's Predation"
|
|
||||||
],
|
|
||||||
"estimatedManaValue": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cardName": "Putrefy",
|
|
||||||
"role": "removal|threat",
|
|
||||||
"reason": "Provides targeted removal and threat mitigation, helping to neutralize opponent strategies",
|
|
||||||
"synergyWith": [
|
|
||||||
"Crux of Fate",
|
|
||||||
"Harald, King of Skemfar"
|
|
||||||
],
|
|
||||||
"estimatedManaValue": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cardName": "Stoneham Ashenhand",
|
|
||||||
"role": "protection|utility",
|
|
||||||
"reason": "Offers protection from big threats and serves as a versatile utility tool in multiple matchups",
|
|
||||||
"synergyWith": [
|
|
||||||
"Krothar, The butcher",
|
|
||||||
"Thrummingbird"
|
|
||||||
],
|
|
||||||
"estimatedManaValue": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"heuristicHints": [
|
|
||||||
"Focus on ramp consistency and removal effects to improve matchup performance."
|
|
||||||
],
|
|
||||||
"manaBaseAdvice": "Consider adding mana lands like Gaea's Cradle or Oran-Rief, the Vastwood for a robust mana base.",
|
|
||||||
"rawTextExplanation": "The deck needs better mana acceleration and card draw efficiency. Adding Ancestral Vision or Ezuri's Predation can help ramp up mana quickly and fetch necessary cards. Incorporating removal effects like Putrefy or Crux of Fate will mitigate opponent threats, while Stoneham Ashenhand provides protection and utility, enhancing overall deck versatility.",
|
|
||||||
"modelUsed": "deepseek-r1-distill-llama-8b",
|
|
||||||
"promptTokens": 563,
|
|
||||||
"completionTokens": 973,
|
|
||||||
"goals": {
|
|
||||||
"targetPowerLevel": 10,
|
|
||||||
"metaFocus": null,
|
|
||||||
"budget": null,
|
|
||||||
"focusAreas": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
# Coach clients — Perplexity, LM Studio
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
"""
|
||||||
|
Commander AI Lab — Perplexity Sonar Structured Output Client
|
||||||
|
═════════════════════════════════════════════════════════════
|
||||||
|
Uses the openai SDK to call Perplexity's chat/completions endpoint
|
||||||
|
with response_format: json_schema for guaranteed structured output.
|
||||||
|
|
||||||
|
Supports both sonar (fast, cheap) and sonar-pro (deep, thorough).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
logger = logging.getLogger("coach.pplx")
|
||||||
|
|
||||||
|
# ── Perplexity Models ─────────────────────────────────────────
|
||||||
|
SONAR = "sonar"
|
||||||
|
SONAR_PRO = "sonar-pro"
|
||||||
|
SONAR_DEEP_RESEARCH = "sonar-deep-research"
|
||||||
|
|
||||||
|
|
||||||
|
class PerplexityResponse:
|
||||||
|
"""Parsed response container from Perplexity API."""
|
||||||
|
def __init__(self, content: str, parsed_json: Optional[dict],
|
||||||
|
prompt_tokens: int, completion_tokens: int,
|
||||||
|
model: str, citations: list[str] = None):
|
||||||
|
self.content = content
|
||||||
|
self.parsed_json = parsed_json
|
||||||
|
self.prompt_tokens = prompt_tokens
|
||||||
|
self.completion_tokens = completion_tokens
|
||||||
|
self.model = model
|
||||||
|
self.citations = citations or []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.parsed_json is not None
|
||||||
|
|
||||||
|
|
||||||
|
class PerplexityClient:
|
||||||
|
"""
|
||||||
|
Perplexity Sonar API client using the openai SDK.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
client = PerplexityClient(api_key="pplx-...")
|
||||||
|
resp = client.chat_structured(
|
||||||
|
system_prompt="You are a deck builder.",
|
||||||
|
user_prompt="Build a deck for...",
|
||||||
|
json_schema=deck_schema,
|
||||||
|
schema_name="DeckList",
|
||||||
|
)
|
||||||
|
if resp.ok:
|
||||||
|
deck = resp.parsed_json
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, model: str = SONAR,
|
||||||
|
timeout: int = 120):
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("Perplexity API key is required")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self.timeout = timeout
|
||||||
|
self._client = OpenAI(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url="https://api.perplexity.ai",
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
def chat_structured(
|
||||||
|
self,
|
||||||
|
system_prompt: str,
|
||||||
|
user_prompt: str,
|
||||||
|
json_schema: dict,
|
||||||
|
schema_name: str = "response",
|
||||||
|
model: str = None,
|
||||||
|
temperature: float = 0.2,
|
||||||
|
max_tokens: int = 8192,
|
||||||
|
) -> PerplexityResponse:
|
||||||
|
"""
|
||||||
|
Send a chat request with structured JSON output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
system_prompt: System message
|
||||||
|
user_prompt: User message
|
||||||
|
json_schema: JSON Schema dict for response_format
|
||||||
|
schema_name: Name for the schema (used in response_format)
|
||||||
|
model: Override model (sonar, sonar-pro)
|
||||||
|
temperature: Sampling temperature (0.0–1.0)
|
||||||
|
max_tokens: Max output tokens
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PerplexityResponse with parsed_json guaranteed by schema
|
||||||
|
"""
|
||||||
|
use_model = model or self.model
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._client.chat.completions.create(
|
||||||
|
model=use_model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
response_format={
|
||||||
|
"type": "json_schema",
|
||||||
|
"json_schema": {
|
||||||
|
"name": schema_name,
|
||||||
|
"schema": json_schema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
choice = response.choices[0]
|
||||||
|
content = choice.message.content or ""
|
||||||
|
usage = response.usage
|
||||||
|
|
||||||
|
# Parse the structured JSON
|
||||||
|
parsed = None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(content)
|
||||||
|
except (json.JSONDecodeError, TypeError) as e:
|
||||||
|
logger.warning("Failed to parse structured response: %s", e)
|
||||||
|
# Fallback: try extracting JSON object
|
||||||
|
parsed = self._extract_json(content)
|
||||||
|
|
||||||
|
# Extract citations if available
|
||||||
|
citations = []
|
||||||
|
if hasattr(response, 'citations') and response.citations:
|
||||||
|
citations = response.citations
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[pplx] model=%s tokens=(%d prompt, %d completion) parsed=%s",
|
||||||
|
use_model,
|
||||||
|
usage.prompt_tokens if usage else 0,
|
||||||
|
usage.completion_tokens if usage else 0,
|
||||||
|
"yes" if parsed else "no",
|
||||||
|
)
|
||||||
|
|
||||||
|
return PerplexityResponse(
|
||||||
|
content=content,
|
||||||
|
parsed_json=parsed,
|
||||||
|
prompt_tokens=usage.prompt_tokens if usage else 0,
|
||||||
|
completion_tokens=usage.completion_tokens if usage else 0,
|
||||||
|
model=use_model,
|
||||||
|
citations=citations,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[pplx] API call failed: %s", e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def chat_plain(
|
||||||
|
self,
|
||||||
|
system_prompt: str,
|
||||||
|
user_prompt: str,
|
||||||
|
model: str = None,
|
||||||
|
temperature: float = 0.2,
|
||||||
|
max_tokens: int = 4096,
|
||||||
|
) -> PerplexityResponse:
|
||||||
|
"""
|
||||||
|
Send a plain chat request without structured output.
|
||||||
|
Used for substitution fallback queries where schema
|
||||||
|
compliance is less critical.
|
||||||
|
"""
|
||||||
|
use_model = model or self.model
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._client.chat.completions.create(
|
||||||
|
model=use_model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
choice = response.choices[0]
|
||||||
|
content = choice.message.content or ""
|
||||||
|
usage = response.usage
|
||||||
|
|
||||||
|
# Try to parse as JSON anyway
|
||||||
|
parsed = self._extract_json(content)
|
||||||
|
|
||||||
|
citations = []
|
||||||
|
if hasattr(response, 'citations') and response.citations:
|
||||||
|
citations = response.citations
|
||||||
|
|
||||||
|
return PerplexityResponse(
|
||||||
|
content=content,
|
||||||
|
parsed_json=parsed,
|
||||||
|
prompt_tokens=usage.prompt_tokens if usage else 0,
|
||||||
|
completion_tokens=usage.completion_tokens if usage else 0,
|
||||||
|
model=use_model,
|
||||||
|
citations=citations,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[pplx] Plain chat failed: %s", e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def achat_structured(self, *args, **kwargs) -> PerplexityResponse:
|
||||||
|
"""Async wrapper — runs structured chat in thread executor."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
None, lambda: self.chat_structured(*args, **kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def achat_plain(self, *args, **kwargs) -> PerplexityResponse:
|
||||||
|
"""Async wrapper — runs plain chat in thread executor."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
None, lambda: self.chat_plain(*args, **kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_json(text: str) -> Optional[dict]:
|
||||||
|
"""Best-effort JSON extraction from possibly noisy text."""
|
||||||
|
# Direct parse
|
||||||
|
try:
|
||||||
|
return json.loads(text.strip())
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Markdown fences
|
||||||
|
import re
|
||||||
|
fence_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', text)
|
||||||
|
if fence_match:
|
||||||
|
try:
|
||||||
|
return json.loads(fence_match.group(1).strip())
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Brace counting
|
||||||
|
start = text.find('{')
|
||||||
|
if start == -1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
depth = 0
|
||||||
|
in_string = False
|
||||||
|
escape_next = False
|
||||||
|
for i in range(start, len(text)):
|
||||||
|
ch = text[i]
|
||||||
|
if escape_next:
|
||||||
|
escape_next = False
|
||||||
|
continue
|
||||||
|
if ch == '\\' and in_string:
|
||||||
|
escape_next = True
|
||||||
|
continue
|
||||||
|
if ch == '"':
|
||||||
|
in_string = not in_string
|
||||||
|
continue
|
||||||
|
if in_string:
|
||||||
|
continue
|
||||||
|
if ch == '{':
|
||||||
|
depth += 1
|
||||||
|
elif ch == '}':
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
try:
|
||||||
|
return json.loads(text[start:i+1])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def check_status(self) -> dict:
|
||||||
|
"""Quick health check — try a minimal API call."""
|
||||||
|
try:
|
||||||
|
response = self._client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "Reply with the word OK."},
|
||||||
|
{"role": "user", "content": "Status check."},
|
||||||
|
],
|
||||||
|
max_tokens=10,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"connected": True,
|
||||||
|
"model": self.model,
|
||||||
|
"response": response.choices[0].message.content,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"connected": False,
|
||||||
|
"model": self.model,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
@@ -138,6 +138,18 @@ class CoachService:
|
|||||||
6. Parse response
|
6. Parse response
|
||||||
7. Persist session
|
7. Persist session
|
||||||
"""
|
"""
|
||||||
|
# 0. Auto-load embeddings if not loaded
|
||||||
|
if not self.embeddings.loaded:
|
||||||
|
logger.info("Embeddings not loaded — attempting auto-load...")
|
||||||
|
try:
|
||||||
|
loaded = self.embeddings.load()
|
||||||
|
if loaded:
|
||||||
|
logger.info("Embeddings auto-loaded: %d cards", self.embeddings.card_count)
|
||||||
|
else:
|
||||||
|
logger.warning("Embeddings auto-load returned False")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Embeddings auto-load failed: %s", e)
|
||||||
|
|
||||||
# 1. Load deck report
|
# 1. Load deck report
|
||||||
report = self.load_deck_report(deck_id)
|
report = self.load_deck_report(deck_id)
|
||||||
if report is None:
|
if report is None:
|
||||||
@@ -158,6 +170,15 @@ class CoachService:
|
|||||||
if c.impactScore < UNDERPERFORMER_IMPACT_THRESHOLD
|
if c.impactScore < UNDERPERFORMER_IMPACT_THRESHOLD
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# If still no underperformers and we have cards, pick bottom N by impact
|
||||||
|
if not underperformers and report.cards:
|
||||||
|
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
|
||||||
|
# Take the bottom 5 non-land cards as candidates for cuts
|
||||||
|
underperformers = [
|
||||||
|
c.name for c in sorted_cards[:5]
|
||||||
|
if not any(t in (c.tags or []) for t in [])
|
||||||
|
][:MAX_UNDERPERFORMERS]
|
||||||
|
|
||||||
if self.embeddings.loaded:
|
if self.embeddings.loaded:
|
||||||
for card_name in underperformers:
|
for card_name in underperformers:
|
||||||
matches = self.embeddings.find_replacements(
|
matches = self.embeddings.find_replacements(
|
||||||
|
|||||||
+22
-2
@@ -33,9 +33,29 @@ MAX_CANDIDATES_PER_UNDERPERFORMER = 10
|
|||||||
MAX_UNDERPERFORMERS = 8
|
MAX_UNDERPERFORMERS = 8
|
||||||
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
|
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
|
||||||
|
|
||||||
# ── LLM Generation Settings ────────────────────────────────
|
# ── LLM Generation Settings (Coach — LM Studio) ──────────────
|
||||||
DEFAULT_TEMPERATURE = 0.7
|
DEFAULT_TEMPERATURE = 0.7
|
||||||
DEFAULT_MAX_TOKENS = 2048
|
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
|
||||||
|
# so we need a generous limit to avoid truncation
|
||||||
|
DEFAULT_MAX_TOKENS = 8192
|
||||||
|
|
||||||
|
# ── Perplexity Deck Generation Settings ───────────────────────
|
||||||
|
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
|
||||||
|
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "perplexity")
|
||||||
|
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
|
||||||
|
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "sonar")
|
||||||
|
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
|
||||||
|
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "8192"))
|
||||||
|
|
||||||
|
# ── Smart Substitution Settings ───────────────────────────────
|
||||||
|
# Minimum embedding similarity to accept a substitute without Perplexity fallback
|
||||||
|
SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY", "0.75"))
|
||||||
|
# Maximum number of alternatives to suggest per missing card
|
||||||
|
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
|
||||||
|
# Model for substitution fallback (sonar is fine — small focused queries)
|
||||||
|
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "sonar")
|
||||||
|
# Enable Perplexity fallback for low-confidence embedding matches
|
||||||
|
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
|
||||||
|
|
||||||
# ── Ensure directories exist ───────────────────────────────
|
# ── Ensure directories exist ───────────────────────────────
|
||||||
def ensure_dirs():
|
def ensure_dirs():
|
||||||
|
|||||||
+90
-18
@@ -85,32 +85,104 @@ class LMStudioClient:
|
|||||||
body["response_format"] = {"type": "json_object"}
|
body["response_format"] = {"type": "json_object"}
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_think_tags(text: str) -> str:
|
||||||
|
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks from LLM output."""
|
||||||
|
import re
|
||||||
|
# Remove all <think>...</think> blocks (greedy, handles multiline)
|
||||||
|
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_json_object(text: str) -> Optional[dict]:
|
||||||
|
"""Find and parse the first complete JSON object in text.
|
||||||
|
Uses brace-counting to handle nested objects reliably."""
|
||||||
|
start = text.find('{')
|
||||||
|
if start == -1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
depth = 0
|
||||||
|
in_string = False
|
||||||
|
escape_next = False
|
||||||
|
|
||||||
|
for i in range(start, len(text)):
|
||||||
|
ch = text[i]
|
||||||
|
if escape_next:
|
||||||
|
escape_next = False
|
||||||
|
continue
|
||||||
|
if ch == '\\':
|
||||||
|
if in_string:
|
||||||
|
escape_next = True
|
||||||
|
continue
|
||||||
|
if ch == '"':
|
||||||
|
in_string = not in_string
|
||||||
|
continue
|
||||||
|
if in_string:
|
||||||
|
continue
|
||||||
|
if ch == '{':
|
||||||
|
depth += 1
|
||||||
|
elif ch == '}':
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
candidate = text[start:i+1]
|
||||||
|
try:
|
||||||
|
return json.loads(candidate)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
# Malformed — try next opening brace
|
||||||
|
next_start = text.find('{', start + 1)
|
||||||
|
if next_start != -1 and next_start < i:
|
||||||
|
start = next_start
|
||||||
|
depth = 1 # we're already past this brace
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
def _parse_response(self, raw: dict) -> LLMResponse:
|
def _parse_response(self, raw: dict) -> LLMResponse:
|
||||||
"""Extract content and usage from OpenAI-format response."""
|
"""Extract content and usage from OpenAI-format response.
|
||||||
|
Handles DeepSeek-R1 <think> tags, markdown fences, and raw JSON."""
|
||||||
choice = raw.get("choices", [{}])[0]
|
choice = raw.get("choices", [{}])[0]
|
||||||
content = choice.get("message", {}).get("content", "")
|
content = choice.get("message", {}).get("content", "")
|
||||||
usage = raw.get("usage", {})
|
usage = raw.get("usage", {})
|
||||||
|
|
||||||
# Try to parse content as JSON
|
# Step 1: Strip <think>...</think> blocks (DeepSeek-R1)
|
||||||
|
cleaned = self._strip_think_tags(content)
|
||||||
|
|
||||||
|
# Step 2: Try direct JSON parse on cleaned content
|
||||||
parsed = None
|
parsed = None
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(content)
|
parsed = json.loads(cleaned)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
# Try to extract JSON from markdown fences
|
pass
|
||||||
if "```json" in content:
|
|
||||||
start = content.index("```json") + 7
|
# Step 3: Try extracting from markdown fences
|
||||||
end = content.index("```", start)
|
if parsed is None and "```json" in cleaned:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(content[start:end].strip())
|
start = cleaned.index("```json") + 7
|
||||||
except (json.JSONDecodeError, ValueError):
|
end = cleaned.index("```", start)
|
||||||
pass
|
parsed = json.loads(cleaned[start:end].strip())
|
||||||
elif "```" in content:
|
except (json.JSONDecodeError, ValueError, IndexError):
|
||||||
start = content.index("```") + 3
|
pass
|
||||||
end = content.index("```", start)
|
if parsed is None and "```" in cleaned:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(content[start:end].strip())
|
start = cleaned.index("```") + 3
|
||||||
except (json.JSONDecodeError, ValueError):
|
end = cleaned.index("```", start)
|
||||||
pass
|
parsed = json.loads(cleaned[start:end].strip())
|
||||||
|
except (json.JSONDecodeError, ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Step 4: Brute-force — find the first valid JSON object in the text
|
||||||
|
if parsed is None:
|
||||||
|
parsed = self._extract_json_object(cleaned)
|
||||||
|
|
||||||
|
# Step 5: If still nothing, try on the original content (in case
|
||||||
|
# think-tag stripping was too aggressive)
|
||||||
|
if parsed is None and content != cleaned:
|
||||||
|
parsed = self._extract_json_object(content)
|
||||||
|
|
||||||
|
if parsed is not None:
|
||||||
|
logger.info("Successfully parsed JSON from LLM response")
|
||||||
|
else:
|
||||||
|
logger.warning("Could not extract JSON from LLM response (%d chars)",
|
||||||
|
len(content))
|
||||||
|
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=content,
|
content=content,
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ RULES:
|
|||||||
- Base suggestions on the simulation data provided, not general "goodstuff" advice.
|
- Base suggestions on the simulation data provided, not general "goodstuff" advice.
|
||||||
- Consider mana curve, role balance (ramp, draw, removal, threats), and synergy.
|
- Consider mana curve, role balance (ramp, draw, removal, threats), and synergy.
|
||||||
- When suggesting replacements, prefer cards that fill the same functional role.
|
- When suggesting replacements, prefer cards that fill the same functional role.
|
||||||
|
- IMPORTANT: Suggest 3-5 DIFFERENT cards to cut and 3-5 DIFFERENT cards to add.
|
||||||
|
- Each suggested add MUST be a different card — never suggest the same card twice.
|
||||||
|
- Vary your suggestions across different functional roles (ramp, draw, removal, threats, utility).
|
||||||
|
- If the deck is missing a functional role (e.g., no card draw, no removal), prioritize adding cards for that role.
|
||||||
|
- If candidate replacements are provided from embeddings search, prefer those over generic suggestions.
|
||||||
{goals_section}
|
{goals_section}
|
||||||
|
|
||||||
You MUST respond ONLY with valid JSON matching this exact structure:
|
You MUST respond ONLY with valid JSON matching this exact structure:
|
||||||
@@ -117,7 +122,7 @@ def _format_curve(buckets: List[int]) -> str:
|
|||||||
"""Format mana curve buckets as a readable string."""
|
"""Format mana curve buckets as a readable string."""
|
||||||
labels = ["0", "1", "2", "3", "4", "5", "6", "7+"]
|
labels = ["0", "1", "2", "3", "4", "5", "6", "7+"]
|
||||||
parts = [f"{labels[i]}={buckets[i]}" for i in range(min(len(buckets), 8)) if buckets[i] > 0]
|
parts = [f"{labels[i]}={buckets[i]}" for i in range(min(len(buckets), 8)) if buckets[i] > 0]
|
||||||
return ", ".join(parts) if parts else "unknown"
|
return ", ".join(parts) if parts else "not available (analyze deck structure manually)"
|
||||||
|
|
||||||
|
|
||||||
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
|
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
|
||||||
@@ -151,7 +156,11 @@ def _format_underperformers(cards: List[CardPerformance],
|
|||||||
def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
||||||
"""Format replacement candidates grouped by the card they'd replace."""
|
"""Format replacement candidates grouped by the card they'd replace."""
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return "No candidate replacements found."
|
return ("No embedding-based candidates available. "
|
||||||
|
"Use your MTG knowledge to suggest cards that fit the deck's "
|
||||||
|
"color identity, strategy, and missing functional roles. "
|
||||||
|
"Suggest at least 3 different cards across different roles "
|
||||||
|
"(ramp, draw, removal, threats, utility).")
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
|
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
|
||||||
@@ -161,7 +170,12 @@ def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
|||||||
types = r.get("types", "")
|
types = r.get("types", "")
|
||||||
mv = r.get("mana_value", "?")
|
mv = r.get("mana_value", "?")
|
||||||
sim = r.get("similarity", 0)
|
sim = r.get("similarity", 0)
|
||||||
|
text_preview = r.get("text", "")[:80]
|
||||||
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
|
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
|
||||||
|
if text_preview:
|
||||||
|
lines.append(f" Oracle: {text_preview}")
|
||||||
|
lines.append("\nChoose from these candidates when possible, but also suggest ")
|
||||||
|
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# Coach prompts — Deck generation and substitution templates
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
Commander AI Lab — Deck Generation System Prompt
|
||||||
|
═════════════════════════════════════════════════
|
||||||
|
"""
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """You are an elite Magic: The Gathering Commander (EDH) deck builder with encyclopedic knowledge of every card ever printed, current metagame trends, and Commander Rules Committee bracket guidelines.
|
||||||
|
|
||||||
|
Your task is to build a complete, legal 100-card Commander deck.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Exactly 100 cards total: 1 commander + 99 other cards
|
||||||
|
- No more than 1 copy of any card except basic lands
|
||||||
|
- All cards must share the commander's color identity
|
||||||
|
- All cards must be legal in Commander format
|
||||||
|
- Include 36-38 lands (mix of basics, dual lands, utility lands, and color fixing)
|
||||||
|
- Include approximately: 10 ramp sources, 10 card draw sources, 8-10 targeted removal, 2-3 board wipes, 3-5 protection pieces
|
||||||
|
- Build a cohesive strategy around the commander's abilities
|
||||||
|
|
||||||
|
BRACKET RULES (Commander Rules Committee):
|
||||||
|
- Bracket 1 (Casual): Precon-level. No tutors, no fast mana, no infinite combos, no mass land destruction. Game Changers: 0
|
||||||
|
- Bracket 2 (Upgraded Casual): Moderate power. Limited tutors (1-2 total), no 2-card infinite combos, no fast mana except Sol Ring. Game Changers: 0-1
|
||||||
|
- Bracket 3 (High Power): Strong but fair. Tutors allowed, efficient combos allowed but not hyper-optimized. No free counterspells, no Mana Crypt/Vault. Game Changers: up to 3
|
||||||
|
- Bracket 4 (cEDH): Maximum power. Everything legal is fair game. No restrictions.
|
||||||
|
|
||||||
|
For each card, assign functional role_tags from this list:
|
||||||
|
ramp, card_draw, removal, board_wipe, protection, finisher, combo_piece, utility, lord, sac_outlet, token_generator, recursion, tutor, counter, lifegain, graveyard_hate, mana_rock, mana_dork, anthem
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- When a COLLECTION SUMMARY is provided, STRONGLY prefer cards from the collection
|
||||||
|
- When a BUDGET is specified, respect it strictly
|
||||||
|
- Provide realistic USD price estimates
|
||||||
|
- The commander MUST be included in the cards list with its real category (Creature, Planeswalker, etc.)
|
||||||
|
- Assign the correct bracket level based on the cards chosen and combos present
|
||||||
|
- List Game Changer cards explicitly in the bracket section"""
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
Commander AI Lab — Deck Generation User Prompt Builder
|
||||||
|
═══════════════════════════════════════════════════════
|
||||||
|
Constructs the user message for Perplexity deck generation
|
||||||
|
from a DeckGenV3Request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_prompt(
|
||||||
|
commander: str,
|
||||||
|
commander_type: str = "",
|
||||||
|
color_identity: List[str] = None,
|
||||||
|
strategy: str = "",
|
||||||
|
target_bracket: int = 3,
|
||||||
|
budget_usd: float = None,
|
||||||
|
budget_mode: str = "total",
|
||||||
|
omit_cards: List[str] = None,
|
||||||
|
collection_summary: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Build the user prompt for deck generation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
commander: Commander card name
|
||||||
|
commander_type: Type line from Scryfall
|
||||||
|
color_identity: List of color letters, e.g. ["U", "B"]
|
||||||
|
strategy: User-specified strategy focus (e.g. "zombie tokens")
|
||||||
|
target_bracket: Target bracket level 1-4
|
||||||
|
budget_usd: Optional budget constraint
|
||||||
|
budget_mode: "total" for total deck cost, "per_card" for per-card max
|
||||||
|
omit_cards: Cards to exclude
|
||||||
|
collection_summary: Pre-built collection summary text block
|
||||||
|
"""
|
||||||
|
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Build a complete 100-card Commander deck.",
|
||||||
|
f"",
|
||||||
|
f"COMMANDER: {commander}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if commander_type:
|
||||||
|
lines.append(f"TYPE: {commander_type}")
|
||||||
|
|
||||||
|
lines.append(f"COLOR IDENTITY: {ci_str}")
|
||||||
|
lines.append(f"TARGET BRACKET: {target_bracket}")
|
||||||
|
|
||||||
|
if strategy:
|
||||||
|
lines.append(f"STRATEGY FOCUS: {strategy}")
|
||||||
|
|
||||||
|
if budget_usd:
|
||||||
|
if budget_mode == "per_card":
|
||||||
|
lines.append(f"BUDGET: Max ${budget_usd:.0f} per card. No card should exceed this price.")
|
||||||
|
else:
|
||||||
|
lines.append(f"BUDGET: ${budget_usd:.0f} total deck cost. Stay within budget.")
|
||||||
|
|
||||||
|
if omit_cards:
|
||||||
|
lines.append(f"OMIT LIST (do NOT include these cards): {', '.join(omit_cards)}")
|
||||||
|
|
||||||
|
if collection_summary:
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append(collection_summary)
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append(f"STRONGLY PREFER cards from the collection when they fit the strategy.")
|
||||||
|
else:
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append(f"No collection data available — suggest the best cards regardless of ownership.")
|
||||||
|
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append(f"Build the deck as a structured JSON response. Include the commander in the cards list.")
|
||||||
|
lines.append(f"Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Commander AI Lab — Substitution Prompt Templates
|
||||||
|
═════════════════════════════════════════════════
|
||||||
|
Prompts for the Perplexity fallback substitution pass.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SUBSTITUTION_SYSTEM = """You are an expert Magic: The Gathering Commander deck builder specializing in card substitutions.
|
||||||
|
|
||||||
|
Given a list of cards that a player does NOT own, suggest the best replacement from their available collection. Each substitute should:
|
||||||
|
1. Fill the same functional role (ramp → ramp, removal → removal, lord → lord)
|
||||||
|
2. Be within the same color identity
|
||||||
|
3. Have a similar mana cost (within ±2 CMC)
|
||||||
|
4. Maintain the deck's strategy and synergies as much as possible
|
||||||
|
|
||||||
|
Only suggest cards from the ALLOWED CARDS LIST provided. Do not suggest cards outside that list.
|
||||||
|
If no good substitute exists for a card, omit it from the substitutions array."""
|
||||||
|
|
||||||
|
|
||||||
|
def build_substitution_prompt(
|
||||||
|
missing_cards: list[dict],
|
||||||
|
allowed_cards: list[str],
|
||||||
|
commander: str,
|
||||||
|
color_identity: list[str],
|
||||||
|
strategy: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Build the user prompt for batch substitution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
missing_cards: List of dicts with 'name', 'role_tags', 'category', 'reason'
|
||||||
|
allowed_cards: List of card names the player owns that can be used
|
||||||
|
commander: Commander name
|
||||||
|
color_identity: Deck color identity
|
||||||
|
strategy: Deck strategy summary
|
||||||
|
"""
|
||||||
|
lines = [
|
||||||
|
f"DECK: {commander} ({', '.join(color_identity)})",
|
||||||
|
]
|
||||||
|
if strategy:
|
||||||
|
lines.append(f"STRATEGY: {strategy}")
|
||||||
|
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append(f"CARDS THAT NEED REPLACING ({len(missing_cards)}):")
|
||||||
|
for card in missing_cards:
|
||||||
|
roles = ", ".join(card.get("role_tags", []))
|
||||||
|
lines.append(f" - {card['name']} [{card.get('category', '?')}] (roles: {roles}) — {card.get('reason', '')}")
|
||||||
|
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append(f"ALLOWED CARDS (player's collection, {len(allowed_cards)} cards):")
|
||||||
|
# Group into chunks to stay readable
|
||||||
|
chunk_size = 20
|
||||||
|
for i in range(0, len(allowed_cards), chunk_size):
|
||||||
|
chunk = allowed_cards[i:i+chunk_size]
|
||||||
|
lines.append(f" {', '.join(chunk)}")
|
||||||
|
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append(f"For each missing card, suggest the BEST substitute from the allowed list.")
|
||||||
|
lines.append(f"Return structured JSON with the substitutions array.")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
+361
-57
@@ -4,17 +4,20 @@ Commander AI Lab — Deck Report Generator (Python)
|
|||||||
Reads batch result JSON files from results/ and generates
|
Reads batch result JSON files from results/ and generates
|
||||||
deck report JSONs in deck-reports/ that the coach service consumes.
|
deck report JSONs in deck-reports/ that the coach service consumes.
|
||||||
|
|
||||||
This is the Python equivalent of Java ReportAggregator, allowing
|
Supports both Java batch format and DeepSeek batch format.
|
||||||
report generation without a separate Java invocation.
|
Populates deck structure (land count, curve, card types, functional roles)
|
||||||
|
from the deck builder database when available.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
|
|
||||||
from .config import DECK_REPORTS_DIR
|
from .config import DECK_REPORTS_DIR, LAB_ROOT
|
||||||
|
|
||||||
logger = logging.getLogger("coach.report_generator")
|
logger = logging.getLogger("coach.report_generator")
|
||||||
|
|
||||||
@@ -60,7 +63,7 @@ def generate_deck_reports(results_dir: str, reports_dir: str = None) -> List[str
|
|||||||
if name and name not in deck_seats:
|
if name and name not in deck_seats:
|
||||||
deck_seats[name] = {
|
deck_seats[name] = {
|
||||||
"seatIndex": di.get("seatIndex", 0),
|
"seatIndex": di.get("seatIndex", 0),
|
||||||
"commanderName": di.get("commanderName", ""),
|
"commanderName": di.get("commanderName", di.get("commander", "")),
|
||||||
"colorIdentity": di.get("colorIdentity", []),
|
"colorIdentity": di.get("colorIdentity", []),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +126,7 @@ def generate_single_deck_report(batch_json_path: str, reports_dir: str = None) -
|
|||||||
continue
|
continue
|
||||||
deck_info = {
|
deck_info = {
|
||||||
"seatIndex": di.get("seatIndex", 0),
|
"seatIndex": di.get("seatIndex", 0),
|
||||||
"commanderName": di.get("commanderName", ""),
|
"commanderName": di.get("commanderName", di.get("commander", "")),
|
||||||
"colorIdentity": di.get("colorIdentity", []),
|
"colorIdentity": di.get("colorIdentity", []),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
@@ -144,9 +147,242 @@ def generate_single_deck_report(batch_json_path: str, reports_dir: str = None) -
|
|||||||
return generated
|
return generated
|
||||||
|
|
||||||
|
|
||||||
|
# ── Database helpers for deck structure ──────────────────────
|
||||||
|
|
||||||
|
def _get_deck_card_metadata(deck_name: str) -> Optional[List[dict]]:
|
||||||
|
"""
|
||||||
|
Look up card metadata (type_line, cmc, oracle_text, mana_cost, keywords)
|
||||||
|
from the deck builder database for the given deck name.
|
||||||
|
Returns None if DB not available or deck not found.
|
||||||
|
"""
|
||||||
|
db_path = LAB_ROOT / "deckbuilder.db"
|
||||||
|
if not db_path.exists():
|
||||||
|
# Try alternate location
|
||||||
|
db_path = LAB_ROOT / "data" / "deckbuilder.db"
|
||||||
|
if not db_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM decks WHERE name = ? COLLATE NOCASE", (deck_name,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
deck_id = row["id"]
|
||||||
|
cards = conn.execute("""
|
||||||
|
SELECT dc.card_name, dc.quantity, dc.is_commander,
|
||||||
|
ce.type_line, ce.cmc, ce.oracle_text,
|
||||||
|
ce.keywords, ce.mana_cost, ce.color_identity
|
||||||
|
FROM deck_cards dc
|
||||||
|
LEFT JOIN collection_entries ce ON ce.scryfall_id = dc.scryfall_id
|
||||||
|
WHERE dc.deck_id = ?
|
||||||
|
""", (deck_id,)).fetchall()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for r in cards:
|
||||||
|
for _ in range(r["quantity"] or 1):
|
||||||
|
result.append({
|
||||||
|
"name": r["card_name"],
|
||||||
|
"type_line": r["type_line"] or "",
|
||||||
|
"cmc": r["cmc"] or 0,
|
||||||
|
"oracle_text": r["oracle_text"] or "",
|
||||||
|
"keywords": r["keywords"] or "",
|
||||||
|
"mana_cost": r["mana_cost"] or "",
|
||||||
|
"is_commander": r["is_commander"] or 0,
|
||||||
|
})
|
||||||
|
conn.close()
|
||||||
|
return result if result else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("DB lookup failed for deck '%s': %s", deck_name, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_structure(deck_cards: List[dict]) -> dict:
|
||||||
|
"""Compute deck structure (landCount, curveBuckets, cardTypeCounts, functionalCounts)
|
||||||
|
from card metadata."""
|
||||||
|
land_count = 0
|
||||||
|
curve_buckets = [0] * 8 # CMC 0, 1, 2, 3, 4, 5, 6, 7+
|
||||||
|
card_type_counts: Dict[str, int] = {}
|
||||||
|
functional_counts: Dict[str, int] = {}
|
||||||
|
|
||||||
|
for card in deck_cards:
|
||||||
|
type_line = card.get("type_line", "").lower()
|
||||||
|
cmc = card.get("cmc", 0) or 0
|
||||||
|
oracle = (card.get("oracle_text", "") or "").lower()
|
||||||
|
keywords = (card.get("keywords", "") or "").lower()
|
||||||
|
|
||||||
|
# Determine primary type
|
||||||
|
primary_type = "Other"
|
||||||
|
if "land" in type_line:
|
||||||
|
primary_type = "Land"
|
||||||
|
land_count += 1
|
||||||
|
elif "creature" in type_line:
|
||||||
|
primary_type = "Creature"
|
||||||
|
elif "instant" in type_line:
|
||||||
|
primary_type = "Instant"
|
||||||
|
elif "sorcery" in type_line:
|
||||||
|
primary_type = "Sorcery"
|
||||||
|
elif "artifact" in type_line:
|
||||||
|
primary_type = "Artifact"
|
||||||
|
elif "enchantment" in type_line:
|
||||||
|
primary_type = "Enchantment"
|
||||||
|
elif "planeswalker" in type_line:
|
||||||
|
primary_type = "Planeswalker"
|
||||||
|
|
||||||
|
card_type_counts[primary_type] = card_type_counts.get(primary_type, 0) + 1
|
||||||
|
|
||||||
|
# Mana curve (exclude lands)
|
||||||
|
if primary_type != "Land":
|
||||||
|
bucket = min(int(cmc), 7)
|
||||||
|
curve_buckets[bucket] += 1
|
||||||
|
|
||||||
|
# Functional role classification
|
||||||
|
role = _classify_role(oracle, keywords, type_line, primary_type)
|
||||||
|
if role:
|
||||||
|
functional_counts[role] = functional_counts.get(role, 0) + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"landCount": land_count,
|
||||||
|
"curveBuckets": curve_buckets,
|
||||||
|
"cardTypeCounts": card_type_counts,
|
||||||
|
"functionalCounts": functional_counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_role(oracle: str, keywords: str, type_line: str, primary_type: str) -> str:
|
||||||
|
"""Classify a card's functional role from its oracle text and type."""
|
||||||
|
if primary_type == "Land":
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Ramp detection
|
||||||
|
if any(phrase in oracle for phrase in [
|
||||||
|
"add {", "add one mana", "search your library for a basic land",
|
||||||
|
"search your library for a land", "mana of any", "additional land",
|
||||||
|
]):
|
||||||
|
return "ramp"
|
||||||
|
|
||||||
|
# Card draw
|
||||||
|
if any(phrase in oracle for phrase in [
|
||||||
|
"draw a card", "draw two", "draw three", "draw cards",
|
||||||
|
"draw x card", "draws a card",
|
||||||
|
]):
|
||||||
|
return "draw"
|
||||||
|
|
||||||
|
# Removal
|
||||||
|
if any(phrase in oracle for phrase in [
|
||||||
|
"destroy target", "exile target", "deals damage to target",
|
||||||
|
"-x/-x", "destroy all", "exile all", "return target",
|
||||||
|
"counter target spell",
|
||||||
|
]):
|
||||||
|
return "removal"
|
||||||
|
|
||||||
|
# Protection / interaction
|
||||||
|
if any(phrase in oracle for phrase in [
|
||||||
|
"hexproof", "indestructible", "protection from",
|
||||||
|
"shroud", "ward",
|
||||||
|
]) or any(kw in keywords for kw in ["hexproof", "indestructible", "ward"]):
|
||||||
|
return "protection"
|
||||||
|
|
||||||
|
# Tutor
|
||||||
|
if "search your library" in oracle and "land" not in oracle:
|
||||||
|
return "tutor"
|
||||||
|
|
||||||
|
# Threat / finisher (high-power creatures, planeswalkers)
|
||||||
|
if primary_type == "Planeswalker":
|
||||||
|
return "threat"
|
||||||
|
if primary_type == "Creature" and (
|
||||||
|
"whenever" in oracle or "each" in oracle or "all" in oracle
|
||||||
|
):
|
||||||
|
return "threat"
|
||||||
|
|
||||||
|
# Default: utility
|
||||||
|
return "utility"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Game data extraction helpers ─────────────────────────────
|
||||||
|
|
||||||
|
def _extract_games_from_batch(batch: dict, deck_name: str) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Extract game data from a batch in a normalized format,
|
||||||
|
supporting both DeepSeek and Java batch formats.
|
||||||
|
|
||||||
|
Returns list of:
|
||||||
|
{
|
||||||
|
"won": bool,
|
||||||
|
"turns": int,
|
||||||
|
"opponents": [str],
|
||||||
|
"cardStats": [{"cardName": ..., "drawn": ..., "cast": ..., ...}]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
games = []
|
||||||
|
|
||||||
|
# Find this deck in the batch
|
||||||
|
batch_deck = None
|
||||||
|
for di in batch.get("decks", []):
|
||||||
|
if di.get("deckName", "") == deck_name:
|
||||||
|
batch_deck = di
|
||||||
|
break
|
||||||
|
if batch_deck is None:
|
||||||
|
return games
|
||||||
|
|
||||||
|
# Determine opponent names
|
||||||
|
opponents = [
|
||||||
|
di.get("deckName", "")
|
||||||
|
for di in batch.get("decks", [])
|
||||||
|
if di.get("deckName", "") != deck_name
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Format 1: DeepSeek format ──
|
||||||
|
# Games are nested inside each deck: batch_deck["games"] = [{"winner": 0, "turns": 12, "playerA": {...}, ...}]
|
||||||
|
deck_games = batch_deck.get("games", [])
|
||||||
|
if deck_games:
|
||||||
|
for game in deck_games:
|
||||||
|
if "error" in game and "winner" not in game:
|
||||||
|
continue # Skip errored games
|
||||||
|
|
||||||
|
won = game.get("winner", -1) == 0
|
||||||
|
turns = game.get("turns", 0)
|
||||||
|
games.append({
|
||||||
|
"won": won,
|
||||||
|
"turns": turns,
|
||||||
|
"opponents": opponents,
|
||||||
|
"cardStats": [], # DeepSeek doesn't have per-card stats
|
||||||
|
})
|
||||||
|
return games
|
||||||
|
|
||||||
|
# ── Format 2: Java format ──
|
||||||
|
# Games are at batch level: batch["games"] = [{"totalTurns": ..., "playerResults": [...]}]
|
||||||
|
batch_seat = batch_deck.get("seatIndex", 0)
|
||||||
|
for game in batch.get("games", []):
|
||||||
|
my_result = None
|
||||||
|
for pr in game.get("playerResults", []):
|
||||||
|
if pr.get("seatIndex") == batch_seat:
|
||||||
|
my_result = pr
|
||||||
|
break
|
||||||
|
if my_result is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
won = my_result.get("isWinner", False)
|
||||||
|
turns = game.get("totalTurns", 0)
|
||||||
|
card_stats = my_result.get("cardStats", [])
|
||||||
|
|
||||||
|
games.append({
|
||||||
|
"won": won,
|
||||||
|
"turns": turns,
|
||||||
|
"opponents": opponents,
|
||||||
|
"cardStats": card_stats,
|
||||||
|
})
|
||||||
|
|
||||||
|
return games
|
||||||
|
|
||||||
|
|
||||||
def _build_deck_report(deck_name: str, deck_info: dict, all_batches: list) -> dict:
|
def _build_deck_report(deck_name: str, deck_info: dict, all_batches: list) -> dict:
|
||||||
"""Build a complete deck report from batch data."""
|
"""Build a complete deck report from batch data."""
|
||||||
seat_index = deck_info["seatIndex"]
|
|
||||||
commander = deck_info.get("commanderName", deck_name)
|
commander = deck_info.get("commanderName", deck_name)
|
||||||
colors = deck_info.get("colorIdentity", [])
|
colors = deck_info.get("colorIdentity", [])
|
||||||
|
|
||||||
@@ -154,65 +390,43 @@ def _build_deck_report(deck_name: str, deck_info: dict, all_batches: list) -> di
|
|||||||
total_wins = 0
|
total_wins = 0
|
||||||
total_turns = 0
|
total_turns = 0
|
||||||
|
|
||||||
# Per-card accumulators
|
# Per-card accumulators (from Java format cardStats)
|
||||||
card_map: Dict[str, dict] = {}
|
card_map: Dict[str, dict] = {}
|
||||||
|
|
||||||
# Matchup tracking
|
# Matchup tracking
|
||||||
matchup_map: Dict[str, List[int]] = {} # opponent -> [games, wins]
|
matchup_map: Dict[str, List[int]] = {} # opponent -> [games, wins]
|
||||||
|
|
||||||
for batch in all_batches:
|
for batch in all_batches:
|
||||||
# Check if this deck is in this batch
|
# Update commander/colors from batch deck entry
|
||||||
batch_deck = None
|
|
||||||
for di in batch.get("decks", []):
|
for di in batch.get("decks", []):
|
||||||
if di.get("deckName") == deck_name:
|
if di.get("deckName") == deck_name:
|
||||||
batch_deck = di
|
if not commander or commander == deck_name:
|
||||||
|
commander = di.get("commanderName", di.get("commander", deck_name))
|
||||||
|
if not colors:
|
||||||
|
colors = di.get("colorIdentity", [])
|
||||||
break
|
break
|
||||||
if batch_deck is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
batch_seat = batch_deck.get("seatIndex", seat_index)
|
# Extract normalized game data
|
||||||
|
games = _extract_games_from_batch(batch, deck_name)
|
||||||
|
|
||||||
# Update commander/colors from batch if we don't have them
|
for game in games:
|
||||||
if not commander or commander == deck_name:
|
|
||||||
commander = batch_deck.get("commanderName", deck_name)
|
|
||||||
if not colors:
|
|
||||||
colors = batch_deck.get("colorIdentity", [])
|
|
||||||
|
|
||||||
# Track opponents
|
|
||||||
opponents = [
|
|
||||||
di.get("deckName", "")
|
|
||||||
for di in batch.get("decks", [])
|
|
||||||
if di.get("seatIndex") != batch_seat
|
|
||||||
]
|
|
||||||
|
|
||||||
# Process each game
|
|
||||||
for game in batch.get("games", []):
|
|
||||||
total_games += 1
|
total_games += 1
|
||||||
total_turns += game.get("totalTurns", 0)
|
total_turns += game["turns"]
|
||||||
|
|
||||||
# Find this deck's player result
|
won = game["won"]
|
||||||
my_result = None
|
|
||||||
for pr in game.get("playerResults", []):
|
|
||||||
if pr.get("seatIndex") == batch_seat:
|
|
||||||
my_result = pr
|
|
||||||
break
|
|
||||||
if my_result is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
won = my_result.get("isWinner", False)
|
|
||||||
if won:
|
if won:
|
||||||
total_wins += 1
|
total_wins += 1
|
||||||
|
|
||||||
# Matchups
|
# Matchups
|
||||||
for opp in opponents:
|
for opp in game["opponents"]:
|
||||||
if opp not in matchup_map:
|
if opp not in matchup_map:
|
||||||
matchup_map[opp] = [0, 0]
|
matchup_map[opp] = [0, 0]
|
||||||
matchup_map[opp][0] += 1
|
matchup_map[opp][0] += 1
|
||||||
if won:
|
if won:
|
||||||
matchup_map[opp][1] += 1
|
matchup_map[opp][1] += 1
|
||||||
|
|
||||||
# Per-card stats
|
# Per-card stats (only available from Java format)
|
||||||
for pcs in my_result.get("cardStats", []):
|
for pcs in game.get("cardStats", []):
|
||||||
card_name = pcs.get("cardName", "")
|
card_name = pcs.get("cardName", "")
|
||||||
if not card_name:
|
if not card_name:
|
||||||
continue
|
continue
|
||||||
@@ -313,19 +527,74 @@ def _build_deck_report(deck_name: str, deck_info: dict, all_batches: list) -> di
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ── Populate deck structure from DB ───────────────────────
|
||||||
|
|
||||||
|
structure = {
|
||||||
|
"landCount": 0,
|
||||||
|
"curveBuckets": [0] * 8,
|
||||||
|
"cardTypeCounts": {},
|
||||||
|
"functionalCounts": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
deck_cards_meta = _get_deck_card_metadata(deck_name)
|
||||||
|
if deck_cards_meta:
|
||||||
|
structure = _compute_structure(deck_cards_meta)
|
||||||
|
logger.info("Populated structure for %s from DB: %d lands, types=%s",
|
||||||
|
deck_name, structure["landCount"], structure["cardTypeCounts"])
|
||||||
|
|
||||||
|
# If we have no per-card stats from sim data (DeepSeek format),
|
||||||
|
# create placeholder card entries from the DB so the coach has
|
||||||
|
# card names to work with
|
||||||
|
if not cards and deck_cards_meta:
|
||||||
|
for card_meta in deck_cards_meta:
|
||||||
|
card_name = card_meta["name"]
|
||||||
|
type_line = card_meta.get("type_line", "")
|
||||||
|
# Skip basic lands from the card list sent to coach
|
||||||
|
if "Basic" in type_line and "Land" in type_line:
|
||||||
|
continue
|
||||||
|
cards.append({
|
||||||
|
"name": card_name,
|
||||||
|
"drawnRate": 0.0,
|
||||||
|
"castRate": 0.0,
|
||||||
|
"keptInOpeningHandRate": 0.0,
|
||||||
|
"deadCardRate": 0.0,
|
||||||
|
"impactScore": 0.0,
|
||||||
|
"synergyScore": 0.0,
|
||||||
|
"clunkinessScore": 0.0,
|
||||||
|
"avgTurnCast": None,
|
||||||
|
"avgDamageDealt": 0.0,
|
||||||
|
"tags": _get_card_tags(card_meta),
|
||||||
|
})
|
||||||
|
|
||||||
# ── Identify under/over performers ────────────────────────
|
# ── Identify under/over performers ────────────────────────
|
||||||
|
|
||||||
sorted_by_impact = sorted(cards, key=lambda c: c["impactScore"])
|
|
||||||
|
|
||||||
underperformers = []
|
underperformers = []
|
||||||
for c in sorted_by_impact[:MAX_UNDERPERFORMERS]:
|
|
||||||
if c["impactScore"] < UNDERPERFORMER_THRESHOLD:
|
|
||||||
underperformers.append(c["name"])
|
|
||||||
|
|
||||||
overperformers = []
|
overperformers = []
|
||||||
for c in reversed(sorted_by_impact[-MAX_OVERPERFORMERS:]):
|
|
||||||
if c["impactScore"] > 0:
|
if any(c["impactScore"] != 0 for c in cards):
|
||||||
overperformers.append(c["name"])
|
# We have real sim data — use impact scores
|
||||||
|
sorted_by_impact = sorted(cards, key=lambda c: c["impactScore"])
|
||||||
|
|
||||||
|
for c in sorted_by_impact[:MAX_UNDERPERFORMERS]:
|
||||||
|
if c["impactScore"] < UNDERPERFORMER_THRESHOLD:
|
||||||
|
underperformers.append(c["name"])
|
||||||
|
|
||||||
|
for c in reversed(sorted_by_impact[-MAX_OVERPERFORMERS:]):
|
||||||
|
if c["impactScore"] > 0:
|
||||||
|
overperformers.append(c["name"])
|
||||||
|
elif cards:
|
||||||
|
# No real per-card stats (DeepSeek only) — flag cards with
|
||||||
|
# highest mana cost and least synergy potential as candidates.
|
||||||
|
# This gives the coach SOMETHING to work with.
|
||||||
|
logger.info("No per-card sim data for %s — using DB heuristics for underperformers", deck_name)
|
||||||
|
if deck_cards_meta:
|
||||||
|
# Sort non-land cards by CMC descending — highest cost cards
|
||||||
|
# are most likely underperformers in aggressive/midrange decks
|
||||||
|
nonland = [c for c in deck_cards_meta if "land" not in (c.get("type_line", "") or "").lower()]
|
||||||
|
by_cmc = sorted(nonland, key=lambda c: c.get("cmc", 0) or 0, reverse=True)
|
||||||
|
for c in by_cmc[:MAX_UNDERPERFORMERS]:
|
||||||
|
if (c.get("cmc", 0) or 0) >= 5:
|
||||||
|
underperformers.append(c["name"])
|
||||||
|
|
||||||
# ── Build final report ────────────────────────────────────
|
# ── Build final report ────────────────────────────────────
|
||||||
|
|
||||||
@@ -340,15 +609,50 @@ def _build_deck_report(deck_name: str, deck_info: dict, all_batches: list) -> di
|
|||||||
"perArchetypeWinRates": {},
|
"perArchetypeWinRates": {},
|
||||||
},
|
},
|
||||||
"matchups": matchups,
|
"matchups": matchups,
|
||||||
"structure": {
|
"structure": structure,
|
||||||
"landCount": 0,
|
|
||||||
"curveBuckets": [0] * 8,
|
|
||||||
"cardTypeCounts": {},
|
|
||||||
"functionalCounts": {},
|
|
||||||
},
|
|
||||||
"cards": cards,
|
"cards": cards,
|
||||||
"underperformers": underperformers,
|
"underperformers": underperformers,
|
||||||
"overperformers": overperformers,
|
"overperformers": overperformers,
|
||||||
"knownCombos": [],
|
"knownCombos": [],
|
||||||
"lastUpdated": datetime.now(timezone.utc).isoformat(),
|
"lastUpdated": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_card_tags(card_meta: dict) -> List[str]:
|
||||||
|
"""Derive functional tags from card metadata."""
|
||||||
|
tags = []
|
||||||
|
type_line = (card_meta.get("type_line", "") or "").lower()
|
||||||
|
oracle = (card_meta.get("oracle_text", "") or "").lower()
|
||||||
|
keywords = (card_meta.get("keywords", "") or "").lower()
|
||||||
|
|
||||||
|
role = _classify_role(oracle, keywords, type_line,
|
||||||
|
_primary_type(type_line))
|
||||||
|
if role:
|
||||||
|
tags.append(role)
|
||||||
|
|
||||||
|
if "creature" in type_line:
|
||||||
|
tags.append("creature")
|
||||||
|
if "legendary" in type_line:
|
||||||
|
tags.append("legendary")
|
||||||
|
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_type(type_line: str) -> str:
|
||||||
|
"""Get primary card type from type line."""
|
||||||
|
tl = type_line.lower()
|
||||||
|
if "land" in tl:
|
||||||
|
return "Land"
|
||||||
|
elif "creature" in tl:
|
||||||
|
return "Creature"
|
||||||
|
elif "instant" in tl:
|
||||||
|
return "Instant"
|
||||||
|
elif "sorcery" in tl:
|
||||||
|
return "Sorcery"
|
||||||
|
elif "artifact" in tl:
|
||||||
|
return "Artifact"
|
||||||
|
elif "enchantment" in tl:
|
||||||
|
return "Enchantment"
|
||||||
|
elif "planeswalker" in tl:
|
||||||
|
return "Planeswalker"
|
||||||
|
return "Other"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Coach schemas — Deck generation and substitution
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
Commander AI Lab — Deck Generation JSON Schemas
|
||||||
|
═════════════════════════════════════════════════
|
||||||
|
Pydantic models and JSON Schema dicts for Perplexity
|
||||||
|
structured output deck generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# Pydantic Models (for validation & type checking)
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class DeckCard(BaseModel):
|
||||||
|
"""A single card in a generated deck."""
|
||||||
|
name: str = Field(description="Exact card name as printed")
|
||||||
|
count: int = Field(default=1, description="Number of copies (1 for all except basic lands)")
|
||||||
|
category: str = Field(description="Card category: Creature, Instant, Sorcery, Artifact, Enchantment, Planeswalker, Land, Battle")
|
||||||
|
role_tags: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Functional roles: ramp, card_draw, removal, board_wipe, protection, finisher, combo_piece, utility, lord, sac_outlet, token_generator, recursion, tutor, counter, lifegain, graveyard_hate, mana_rock, mana_dork, anthem"
|
||||||
|
)
|
||||||
|
reason: str = Field(default="", description="Brief explanation of why this card is included")
|
||||||
|
estimated_price_usd: float = Field(default=0.0, description="Estimated TCG market price in USD")
|
||||||
|
synergy_with: List[str] = Field(default_factory=list, description="Names of other cards in the deck this synergizes with")
|
||||||
|
|
||||||
|
|
||||||
|
class BracketInfo(BaseModel):
|
||||||
|
"""Commander bracket classification (1-4 per Rules Committee)."""
|
||||||
|
level: int = Field(ge=1, le=4, description="Bracket level 1-4")
|
||||||
|
reasoning: str = Field(description="Why this bracket was assigned")
|
||||||
|
game_changers: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Cards that count as 'Game Changers' for bracket classification"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeckReasoning(BaseModel):
|
||||||
|
"""Strategic reasoning behind deck construction."""
|
||||||
|
strategy: str = Field(description="Core game plan and win conditions")
|
||||||
|
mana_curve: str = Field(description="How the mana curve was designed")
|
||||||
|
key_synergies: str = Field(description="Most important card interactions")
|
||||||
|
budget_notes: str = Field(default="", description="How budget constraints were managed")
|
||||||
|
bracket_compliance: str = Field(default="", description="How bracket rules were respected")
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedDeckList(BaseModel):
|
||||||
|
"""Complete generated deck output from Perplexity."""
|
||||||
|
commander: str = Field(description="Commander card name")
|
||||||
|
strategy_summary: str = Field(description="1-2 sentence strategy overview")
|
||||||
|
bracket: BracketInfo
|
||||||
|
archetype: str = Field(description="Primary archetype: aggro, midrange, control, combo, stax, voltron, aristocrats, spellslinger, tokens, tribal, group_hug, lands, reanimator")
|
||||||
|
cards: List[DeckCard] = Field(description="Complete card list (commander + 99)")
|
||||||
|
reasoning: DeckReasoning
|
||||||
|
estimated_total_usd: float = Field(default=0.0, description="Estimated total deck cost")
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# JSON Schema dicts (for Perplexity response_format)
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DECK_CARD_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "Exact card name as printed"},
|
||||||
|
"count": {"type": "integer", "description": "Number of copies (1 for all except basic lands)"},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Card category",
|
||||||
|
"enum": ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land", "Battle"],
|
||||||
|
},
|
||||||
|
"role_tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Functional roles: ramp, card_draw, removal, board_wipe, protection, finisher, combo_piece, utility, lord, sac_outlet, token_generator, recursion, tutor, counter, lifegain, graveyard_hate, mana_rock, mana_dork, anthem",
|
||||||
|
},
|
||||||
|
"reason": {"type": "string", "description": "Brief explanation of why this card is included"},
|
||||||
|
"estimated_price_usd": {"type": "number", "description": "Estimated TCG market price in USD"},
|
||||||
|
"synergy_with": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Names of other cards this synergizes with",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "count", "category", "role_tags", "reason"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
BRACKET_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"level": {"type": "integer", "description": "Bracket level 1-4"},
|
||||||
|
"reasoning": {"type": "string", "description": "Why this bracket was assigned"},
|
||||||
|
"game_changers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Cards that are Game Changers for bracket classification",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["level", "reasoning", "game_changers"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
DECK_REASONING_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"strategy": {"type": "string", "description": "Core game plan and win conditions"},
|
||||||
|
"mana_curve": {"type": "string", "description": "How the mana curve was designed"},
|
||||||
|
"key_synergies": {"type": "string", "description": "Most important card interactions"},
|
||||||
|
"budget_notes": {"type": "string", "description": "How budget constraints were managed"},
|
||||||
|
"bracket_compliance": {"type": "string", "description": "How bracket rules were respected"},
|
||||||
|
},
|
||||||
|
"required": ["strategy", "mana_curve", "key_synergies"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
DECK_LIST_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"commander": {"type": "string", "description": "Commander card name"},
|
||||||
|
"strategy_summary": {"type": "string", "description": "1-2 sentence strategy overview"},
|
||||||
|
"bracket": BRACKET_SCHEMA,
|
||||||
|
"archetype": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Primary archetype",
|
||||||
|
"enum": ["aggro", "midrange", "control", "combo", "stax", "voltron", "aristocrats", "spellslinger", "tokens", "tribal", "group_hug", "lands", "reanimator", "other"],
|
||||||
|
},
|
||||||
|
"cards": {
|
||||||
|
"type": "array",
|
||||||
|
"items": DECK_CARD_SCHEMA,
|
||||||
|
"description": "Complete card list (commander + 99)",
|
||||||
|
},
|
||||||
|
"reasoning": DECK_REASONING_SCHEMA,
|
||||||
|
"estimated_total_usd": {"type": "number", "description": "Estimated total deck cost"},
|
||||||
|
},
|
||||||
|
"required": ["commander", "strategy_summary", "bracket", "archetype", "cards", "reasoning", "estimated_total_usd"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Commander AI Lab — Substitution Schemas
|
||||||
|
════════════════════════════════════════
|
||||||
|
Pydantic models and JSON Schema dicts for the
|
||||||
|
Smart Substitution Engine output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# Pydantic Models
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class CardAlternative(BaseModel):
|
||||||
|
"""A single substitute card suggestion."""
|
||||||
|
name: str = Field(description="Exact card name")
|
||||||
|
similarity_score: float = Field(default=0.0, description="Cosine similarity score (0-1)")
|
||||||
|
reason: str = Field(default="", description="Why this card is a good substitute")
|
||||||
|
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'perplexity'")
|
||||||
|
role_overlap: List[str] = Field(default_factory=list, description="Shared functional roles")
|
||||||
|
cmc_delta: float = Field(default=0.0, description="CMC difference from original")
|
||||||
|
|
||||||
|
|
||||||
|
class DeckCardWithStatus(BaseModel):
|
||||||
|
"""A deck card enriched with ownership and substitution data."""
|
||||||
|
name: str
|
||||||
|
count: int = 1
|
||||||
|
category: str = ""
|
||||||
|
role_tags: List[str] = Field(default_factory=list)
|
||||||
|
reason: str = ""
|
||||||
|
estimated_price_usd: float = 0.0
|
||||||
|
synergy_with: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Ownership & substitution
|
||||||
|
owned: bool = True
|
||||||
|
owned_qty: int = 0
|
||||||
|
scryfall_id: str = ""
|
||||||
|
status: str = Field(default="owned", description="owned | substituted | missing")
|
||||||
|
original_card: Optional[str] = Field(default=None, description="Original card name if this is a substitution")
|
||||||
|
alternatives: List[CardAlternative] = Field(default_factory=list)
|
||||||
|
selected_substitute: Optional[str] = Field(default=None, description="User-selected substitute name")
|
||||||
|
|
||||||
|
|
||||||
|
class SubstitutionRequest(BaseModel):
|
||||||
|
"""Request for substitution on a single card."""
|
||||||
|
card_name: str
|
||||||
|
role_tags: List[str] = Field(default_factory=list)
|
||||||
|
category: str = ""
|
||||||
|
cmc: float = 0.0
|
||||||
|
color_identity: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SubstitutionBatchResult(BaseModel):
|
||||||
|
"""Result of a batch substitution pass."""
|
||||||
|
total_cards: int = 0
|
||||||
|
owned_count: int = 0
|
||||||
|
substituted_count: int = 0
|
||||||
|
missing_count: int = 0
|
||||||
|
cards: List[DeckCardWithStatus] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# JSON Schema dicts (for Perplexity fallback substitution)
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SUBSTITUTION_RESPONSE_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"substitutions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"original": {"type": "string", "description": "Card that needs replacing"},
|
||||||
|
"substitute": {"type": "string", "description": "Recommended replacement from the allowed list"},
|
||||||
|
"reason": {"type": "string", "description": "Why this substitute works"},
|
||||||
|
"role_overlap": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Shared functional roles",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["original", "substitute", "reason"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["substitutions"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Coach services — Deck generator V3
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
"""
|
||||||
|
Commander AI Lab — V3 Deck Generator Service
|
||||||
|
═════════════════════════════════════════════
|
||||||
|
Generates Commander decks using Perplexity Sonar
|
||||||
|
structured JSON output, then runs ownership check
|
||||||
|
and smart substitution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
from urllib.request import urlopen, Request
|
||||||
|
|
||||||
|
from ..clients.perplexity_client import PerplexityClient
|
||||||
|
from ..schemas.deck_schema import DECK_LIST_SCHEMA, GeneratedDeckList
|
||||||
|
from ..schemas.substitution_schema import (
|
||||||
|
CardAlternative, DeckCardWithStatus, SubstitutionBatchResult,
|
||||||
|
)
|
||||||
|
from ..prompts.deck_gen_system import SYSTEM_PROMPT
|
||||||
|
from ..prompts.deck_gen_user import build_user_prompt
|
||||||
|
from ..prompts.substitution_prompt import (
|
||||||
|
SUBSTITUTION_SYSTEM, build_substitution_prompt,
|
||||||
|
)
|
||||||
|
from ..config import (
|
||||||
|
DECK_GEN_MODEL, DECK_GEN_TEMPERATURE, DECK_GEN_MAX_TOKENS,
|
||||||
|
SUBSTITUTION_MIN_SIMILARITY, SUBSTITUTION_MAX_ALTERNATIVES,
|
||||||
|
SUBSTITUTION_MODEL, SUBSTITUTION_USE_PPLX_FALLBACK,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("coach.deckgen")
|
||||||
|
|
||||||
|
|
||||||
|
class DeckGeneratorV3:
|
||||||
|
"""
|
||||||
|
V3 Deck Generator using Perplexity structured output.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
1. Resolve commander via Scryfall
|
||||||
|
2. Build collection summary
|
||||||
|
3. Call Perplexity with structured JSON schema
|
||||||
|
4. Validate card names via Scryfall
|
||||||
|
5. Check ownership against collection DB
|
||||||
|
6. Run Smart Substitution (embedding + Perplexity fallback)
|
||||||
|
7. Return finalized deck with substitution data
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, pplx_client: PerplexityClient, db_conn_factory,
|
||||||
|
embedding_index=None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
pplx_client: Configured PerplexityClient instance
|
||||||
|
db_conn_factory: Callable that returns sqlite3.Connection
|
||||||
|
embedding_index: Optional MTGEmbeddingIndex for substitution
|
||||||
|
"""
|
||||||
|
self.pplx = pplx_client
|
||||||
|
self.db_conn_factory = db_conn_factory
|
||||||
|
self.embedding_index = embedding_index
|
||||||
|
|
||||||
|
# ── Step 1: Resolve Commander ────────────────────────────
|
||||||
|
def resolve_commander(self, commander_name: str) -> dict:
|
||||||
|
"""Look up commander on Scryfall to get canonical name, color identity, type."""
|
||||||
|
try:
|
||||||
|
encoded = commander_name.replace(" ", "+")
|
||||||
|
url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}"
|
||||||
|
req = Request(url)
|
||||||
|
req.add_header("User-Agent", "CommanderAILab/3.0")
|
||||||
|
req.add_header("Accept", "application/json")
|
||||||
|
with urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
return {
|
||||||
|
"name": data.get("name", commander_name),
|
||||||
|
"scryfall_id": data.get("id", ""),
|
||||||
|
"color_identity": data.get("color_identity", []),
|
||||||
|
"type_line": data.get("type_line", ""),
|
||||||
|
"mana_cost": data.get("mana_cost", ""),
|
||||||
|
"image_url": data.get("image_uris", {}).get("normal", ""),
|
||||||
|
"oracle_text": data.get("oracle_text", ""),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Scryfall lookup failed for '%s': %s", commander_name, e)
|
||||||
|
return {
|
||||||
|
"name": commander_name,
|
||||||
|
"scryfall_id": "",
|
||||||
|
"color_identity": [],
|
||||||
|
"type_line": "",
|
||||||
|
"mana_cost": "",
|
||||||
|
"image_url": "",
|
||||||
|
"oracle_text": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Step 2: Build Collection Summary ─────────────────────
|
||||||
|
def build_collection_summary(self, color_identity: List[str],
|
||||||
|
max_per_group: int = 20) -> str:
|
||||||
|
"""
|
||||||
|
Build a text summary of owned cards matching the deck's color identity.
|
||||||
|
Groups by card type for the LLM prompt.
|
||||||
|
"""
|
||||||
|
conn = self.db_conn_factory()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT name, type_line, cmc, oracle_text, quantity, tcg_price "
|
||||||
|
"FROM collection_entries WHERE quantity > 0"
|
||||||
|
).fetchall()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to load collection: %s", e)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Filter by color identity
|
||||||
|
ci_set = set(color_identity) if color_identity else None
|
||||||
|
filtered = []
|
||||||
|
for row in rows:
|
||||||
|
name = row["name"]
|
||||||
|
type_line = row["type_line"] or ""
|
||||||
|
# Basic lands and colorless are always allowed
|
||||||
|
if ci_set:
|
||||||
|
# Simple color check from type_line and name
|
||||||
|
# For proper filtering we'd check mana cost but this is good enough for summary
|
||||||
|
card_colors = set()
|
||||||
|
oracle = row["oracle_text"] or ""
|
||||||
|
mana_cost = ""
|
||||||
|
try:
|
||||||
|
mana_cost = row["mana_cost"] or ""
|
||||||
|
except (IndexError, KeyError):
|
||||||
|
pass
|
||||||
|
for sym in ["W", "U", "B", "R", "G"]:
|
||||||
|
if f"{{{sym}}}" in (mana_cost or oracle):
|
||||||
|
card_colors.add(sym)
|
||||||
|
if card_colors and not card_colors.issubset(ci_set):
|
||||||
|
continue
|
||||||
|
filtered.append(row)
|
||||||
|
|
||||||
|
if not filtered:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Group by card type
|
||||||
|
groups = {}
|
||||||
|
for row in filtered:
|
||||||
|
type_line = row["type_line"] or ""
|
||||||
|
if "Creature" in type_line:
|
||||||
|
group = "Creatures"
|
||||||
|
elif "Instant" in type_line:
|
||||||
|
group = "Instants"
|
||||||
|
elif "Sorcery" in type_line:
|
||||||
|
group = "Sorceries"
|
||||||
|
elif "Artifact" in type_line:
|
||||||
|
group = "Artifacts"
|
||||||
|
elif "Enchantment" in type_line:
|
||||||
|
group = "Enchantments"
|
||||||
|
elif "Planeswalker" in type_line:
|
||||||
|
group = "Planeswalkers"
|
||||||
|
elif "Land" in type_line:
|
||||||
|
group = "Lands"
|
||||||
|
else:
|
||||||
|
group = "Other"
|
||||||
|
groups.setdefault(group, []).append(row)
|
||||||
|
|
||||||
|
# Sort each group by popularity/price and build text
|
||||||
|
lines = [f"COLLECTION SUMMARY ({len(filtered)} cards in deck colors):"]
|
||||||
|
for group_name in ["Creatures", "Instants", "Sorceries", "Artifacts",
|
||||||
|
"Enchantments", "Planeswalkers", "Lands", "Other"]:
|
||||||
|
cards = groups.get(group_name, [])
|
||||||
|
if not cards:
|
||||||
|
continue
|
||||||
|
# Sort by price descending (higher value = likely better card)
|
||||||
|
cards.sort(key=lambda r: r["tcg_price"] or 0, reverse=True)
|
||||||
|
card_strs = []
|
||||||
|
for c in cards[:max_per_group]:
|
||||||
|
price = f"${c['tcg_price']:.2f}" if c["tcg_price"] else "$?"
|
||||||
|
card_strs.append(f"{c['name']} ({price})")
|
||||||
|
lines.append(f" {group_name} ({len(cards)}): {', '.join(card_strs)}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# ── Step 3: Generate Deck via Perplexity ─────────────────
|
||||||
|
def generate_deck(
|
||||||
|
self,
|
||||||
|
commander_name: str,
|
||||||
|
strategy: str = "",
|
||||||
|
target_bracket: int = 3,
|
||||||
|
budget_usd: float = None,
|
||||||
|
budget_mode: str = "total",
|
||||||
|
omit_cards: List[str] = None,
|
||||||
|
use_collection: bool = True,
|
||||||
|
model: str = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Generate a complete Commander deck.
|
||||||
|
|
||||||
|
Returns dict with:
|
||||||
|
- commander: dict with Scryfall data
|
||||||
|
- raw_deck: parsed GeneratedDeckList from Perplexity
|
||||||
|
- cards: list of DeckCardWithStatus (after ownership check)
|
||||||
|
- stats: deck statistics
|
||||||
|
- tokens_used: {prompt, completion}
|
||||||
|
"""
|
||||||
|
# Step 1: Resolve commander
|
||||||
|
cmdr = self.resolve_commander(commander_name)
|
||||||
|
color_identity = cmdr["color_identity"]
|
||||||
|
logger.info("Commander: %s, CI: %s", cmdr["name"], color_identity)
|
||||||
|
|
||||||
|
# Step 2: Collection summary
|
||||||
|
collection_block = ""
|
||||||
|
if use_collection:
|
||||||
|
collection_block = self.build_collection_summary(color_identity)
|
||||||
|
logger.info("Collection summary: %d chars",
|
||||||
|
len(collection_block) if collection_block else 0)
|
||||||
|
|
||||||
|
# Step 3: Build prompts
|
||||||
|
user_prompt = build_user_prompt(
|
||||||
|
commander=cmdr["name"],
|
||||||
|
commander_type=cmdr["type_line"],
|
||||||
|
color_identity=color_identity,
|
||||||
|
strategy=strategy,
|
||||||
|
target_bracket=target_bracket,
|
||||||
|
budget_usd=budget_usd,
|
||||||
|
budget_mode=budget_mode,
|
||||||
|
omit_cards=omit_cards,
|
||||||
|
collection_summary=collection_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Call Perplexity with structured output
|
||||||
|
use_model = model or DECK_GEN_MODEL
|
||||||
|
logger.info("Calling Perplexity (%s) for deck generation...", use_model)
|
||||||
|
|
||||||
|
response = self.pplx.chat_structured(
|
||||||
|
system_prompt=SYSTEM_PROMPT,
|
||||||
|
user_prompt=user_prompt,
|
||||||
|
json_schema=DECK_LIST_SCHEMA,
|
||||||
|
schema_name="DeckList",
|
||||||
|
model=use_model,
|
||||||
|
temperature=DECK_GEN_TEMPERATURE,
|
||||||
|
max_tokens=DECK_GEN_MAX_TOKENS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
raise ValueError(
|
||||||
|
f"Perplexity returned invalid response: {response.content[:300]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_deck = response.parsed_json
|
||||||
|
logger.info("Deck generated: %d cards, bracket %d",
|
||||||
|
len(raw_deck.get("cards", [])),
|
||||||
|
raw_deck.get("bracket", {}).get("level", 0))
|
||||||
|
|
||||||
|
# Step 5: Check ownership and build enriched card list
|
||||||
|
cards_with_status = self._check_ownership(raw_deck.get("cards", []))
|
||||||
|
|
||||||
|
# Step 6: Compute stats
|
||||||
|
stats = self._compute_stats(cards_with_status)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"commander": cmdr,
|
||||||
|
"color_identity": color_identity,
|
||||||
|
"raw_deck": raw_deck,
|
||||||
|
"cards": [c.model_dump() for c in cards_with_status],
|
||||||
|
"stats": stats,
|
||||||
|
"strategy_summary": raw_deck.get("strategy_summary", ""),
|
||||||
|
"bracket": raw_deck.get("bracket", {}),
|
||||||
|
"archetype": raw_deck.get("archetype", ""),
|
||||||
|
"reasoning": raw_deck.get("reasoning", {}),
|
||||||
|
"estimated_total_usd": raw_deck.get("estimated_total_usd", 0),
|
||||||
|
"tokens_used": {
|
||||||
|
"prompt": response.prompt_tokens,
|
||||||
|
"completion": response.completion_tokens,
|
||||||
|
},
|
||||||
|
"model": response.model,
|
||||||
|
"citations": response.citations,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Step 5: Ownership Check ──────────────────────────────
|
||||||
|
def _check_ownership(self, cards: List[dict]) -> List[DeckCardWithStatus]:
|
||||||
|
"""
|
||||||
|
Cross-reference generated cards against the collection DB.
|
||||||
|
Sets owned/scryfall_id/owned_qty and real price.
|
||||||
|
"""
|
||||||
|
conn = self.db_conn_factory()
|
||||||
|
enriched = []
|
||||||
|
|
||||||
|
for card in cards:
|
||||||
|
name = card.get("name", "")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Look up in collection
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
|
||||||
|
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
|
||||||
|
(name,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
is_owned = bool(row and row["quantity"] and row["quantity"] > 0)
|
||||||
|
|
||||||
|
enriched_card = DeckCardWithStatus(
|
||||||
|
name=row["name"] if row else name,
|
||||||
|
count=card.get("count", 1),
|
||||||
|
category=card.get("category", ""),
|
||||||
|
role_tags=card.get("role_tags", []),
|
||||||
|
reason=card.get("reason", ""),
|
||||||
|
estimated_price_usd=(
|
||||||
|
round(row["tcg_price"], 2) if row and row["tcg_price"]
|
||||||
|
else card.get("estimated_price_usd", 0)
|
||||||
|
),
|
||||||
|
synergy_with=card.get("synergy_with", []),
|
||||||
|
owned=is_owned,
|
||||||
|
owned_qty=int(row["quantity"]) if row and row["quantity"] else 0,
|
||||||
|
scryfall_id=row["scryfall_id"] if row else "",
|
||||||
|
status="owned" if is_owned else "missing",
|
||||||
|
)
|
||||||
|
enriched.append(enriched_card)
|
||||||
|
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
# ── Step 6: Smart Substitution ───────────────────────────
|
||||||
|
def run_substitution(
|
||||||
|
self,
|
||||||
|
cards: List[DeckCardWithStatus],
|
||||||
|
commander: dict,
|
||||||
|
strategy: str = "",
|
||||||
|
) -> SubstitutionBatchResult:
|
||||||
|
"""
|
||||||
|
Run the Smart Substitution Engine on missing cards.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
a) Identify missing cards
|
||||||
|
b) For each: try embedding similarity (free, fast)
|
||||||
|
c) For low-confidence matches: batch Perplexity fallback
|
||||||
|
d) Auto-select best substitute per card
|
||||||
|
|
||||||
|
Returns SubstitutionBatchResult with all cards updated.
|
||||||
|
"""
|
||||||
|
color_identity = commander.get("color_identity", [])
|
||||||
|
missing = [c for c in cards if not c.owned]
|
||||||
|
owned = [c for c in cards if c.owned]
|
||||||
|
|
||||||
|
if not missing:
|
||||||
|
return SubstitutionBatchResult(
|
||||||
|
total_cards=len(cards),
|
||||||
|
owned_count=len(owned),
|
||||||
|
substituted_count=0,
|
||||||
|
missing_count=0,
|
||||||
|
cards=cards,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Substitution: %d missing / %d owned", len(missing), len(owned))
|
||||||
|
|
||||||
|
# Build list of owned card names for substitution pool
|
||||||
|
conn = self.db_conn_factory()
|
||||||
|
owned_pool_rows = conn.execute(
|
||||||
|
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
|
||||||
|
).fetchall()
|
||||||
|
owned_pool = [r["name"] for r in owned_pool_rows]
|
||||||
|
|
||||||
|
# Exclude cards already in the deck
|
||||||
|
deck_names = set(c.name.lower() for c in cards)
|
||||||
|
available_pool = [n for n in owned_pool if n.lower() not in deck_names]
|
||||||
|
|
||||||
|
# a) Embedding pass
|
||||||
|
needs_pplx_fallback = []
|
||||||
|
for card in missing:
|
||||||
|
embedding_alts = self._embedding_substitution(
|
||||||
|
card, color_identity, available_pool
|
||||||
|
)
|
||||||
|
card.alternatives = embedding_alts
|
||||||
|
|
||||||
|
# Check if best match meets threshold
|
||||||
|
if embedding_alts and embedding_alts[0].similarity_score >= SUBSTITUTION_MIN_SIMILARITY:
|
||||||
|
card.selected_substitute = embedding_alts[0].name
|
||||||
|
card.status = "substituted"
|
||||||
|
# Remove selected from pool
|
||||||
|
if card.selected_substitute.lower() in [n.lower() for n in available_pool]:
|
||||||
|
available_pool = [n for n in available_pool
|
||||||
|
if n.lower() != card.selected_substitute.lower()]
|
||||||
|
else:
|
||||||
|
needs_pplx_fallback.append(card)
|
||||||
|
|
||||||
|
# b) Perplexity fallback for low-confidence matches
|
||||||
|
if needs_pplx_fallback and SUBSTITUTION_USE_PPLX_FALLBACK:
|
||||||
|
self._pplx_substitution(
|
||||||
|
needs_pplx_fallback, available_pool,
|
||||||
|
commander, strategy
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count results
|
||||||
|
substituted = sum(1 for c in cards if c.status == "substituted")
|
||||||
|
still_missing = sum(1 for c in cards if c.status == "missing")
|
||||||
|
|
||||||
|
return SubstitutionBatchResult(
|
||||||
|
total_cards=len(cards),
|
||||||
|
owned_count=len(owned),
|
||||||
|
substituted_count=substituted,
|
||||||
|
missing_count=still_missing,
|
||||||
|
cards=cards,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _embedding_substitution(
|
||||||
|
self,
|
||||||
|
card: DeckCardWithStatus,
|
||||||
|
color_identity: List[str],
|
||||||
|
available_pool: List[str],
|
||||||
|
) -> List[CardAlternative]:
|
||||||
|
"""Find embedding-similar owned cards as substitutes."""
|
||||||
|
if not self.embedding_index or not self.embedding_index.loaded:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Use the embeddings search to find similar cards
|
||||||
|
matches = self.embedding_index.search_similar(
|
||||||
|
query_card=card.name,
|
||||||
|
color_filter=color_identity if color_identity else None,
|
||||||
|
type_filter=card.category if card.category else None,
|
||||||
|
exclude_cards=[card.name],
|
||||||
|
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3, # Over-fetch, then filter to owned
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter to only cards in the available pool
|
||||||
|
pool_lower = set(n.lower() for n in available_pool)
|
||||||
|
alternatives = []
|
||||||
|
for match in matches:
|
||||||
|
if match.name.lower() in pool_lower:
|
||||||
|
# Compute role overlap
|
||||||
|
match_roles = [] # Would need card's role_tags from DB
|
||||||
|
role_overlap = list(set(card.role_tags) & set(match_roles)) if match_roles else []
|
||||||
|
|
||||||
|
alternatives.append(CardAlternative(
|
||||||
|
name=match.name,
|
||||||
|
similarity_score=match.similarity,
|
||||||
|
reason=f"Similar to {card.name} (cosine sim: {match.similarity:.3f})",
|
||||||
|
source="embedding",
|
||||||
|
role_overlap=role_overlap,
|
||||||
|
cmc_delta=match.mana_value - (card.estimated_price_usd or 0),
|
||||||
|
))
|
||||||
|
|
||||||
|
if len(alternatives) >= SUBSTITUTION_MAX_ALTERNATIVES:
|
||||||
|
break
|
||||||
|
|
||||||
|
return alternatives
|
||||||
|
|
||||||
|
def _pplx_substitution(
|
||||||
|
self,
|
||||||
|
missing_cards: List[DeckCardWithStatus],
|
||||||
|
available_pool: List[str],
|
||||||
|
commander: dict,
|
||||||
|
strategy: str,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Batch Perplexity fallback for cards with low embedding confidence.
|
||||||
|
Mutates the cards in-place with substitution data.
|
||||||
|
"""
|
||||||
|
if not available_pool:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build the request
|
||||||
|
missing_dicts = [
|
||||||
|
{
|
||||||
|
"name": c.name,
|
||||||
|
"role_tags": c.role_tags,
|
||||||
|
"category": c.category,
|
||||||
|
"reason": c.reason,
|
||||||
|
}
|
||||||
|
for c in missing_cards
|
||||||
|
]
|
||||||
|
|
||||||
|
user_prompt = build_substitution_prompt(
|
||||||
|
missing_cards=missing_dicts,
|
||||||
|
allowed_cards=available_pool[:200], # Limit to avoid token overflow
|
||||||
|
commander=commander.get("name", ""),
|
||||||
|
color_identity=commander.get("color_identity", []),
|
||||||
|
strategy=strategy,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..schemas.substitution_schema import SUBSTITUTION_RESPONSE_SCHEMA
|
||||||
|
response = self.pplx.chat_structured(
|
||||||
|
system_prompt=SUBSTITUTION_SYSTEM,
|
||||||
|
user_prompt=user_prompt,
|
||||||
|
json_schema=SUBSTITUTION_RESPONSE_SCHEMA,
|
||||||
|
schema_name="SubstitutionResponse",
|
||||||
|
model=SUBSTITUTION_MODEL,
|
||||||
|
temperature=0.1,
|
||||||
|
max_tokens=4096,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
logger.warning("Perplexity substitution fallback failed — non-JSON response")
|
||||||
|
return
|
||||||
|
|
||||||
|
subs = response.parsed_json.get("substitutions", [])
|
||||||
|
logger.info("Perplexity suggested %d substitutions", len(subs))
|
||||||
|
|
||||||
|
# Apply substitutions
|
||||||
|
card_by_name = {c.name.lower(): c for c in missing_cards}
|
||||||
|
for sub in subs:
|
||||||
|
orig = sub.get("original", "").lower()
|
||||||
|
substitute = sub.get("substitute", "")
|
||||||
|
reason = sub.get("reason", "")
|
||||||
|
|
||||||
|
if orig in card_by_name and substitute:
|
||||||
|
card = card_by_name[orig]
|
||||||
|
# Add as top alternative
|
||||||
|
pplx_alt = CardAlternative(
|
||||||
|
name=substitute,
|
||||||
|
similarity_score=0.80, # Nominal score for Perplexity suggestions
|
||||||
|
reason=reason,
|
||||||
|
source="perplexity",
|
||||||
|
role_overlap=sub.get("role_overlap", []),
|
||||||
|
)
|
||||||
|
# Insert at front
|
||||||
|
card.alternatives.insert(0, pplx_alt)
|
||||||
|
card.selected_substitute = substitute
|
||||||
|
card.status = "substituted"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Perplexity substitution fallback error: %s", e)
|
||||||
|
|
||||||
|
# ── Stats ────────────────────────────────────────────────
|
||||||
|
@staticmethod
|
||||||
|
def _compute_stats(cards: List[DeckCardWithStatus]) -> dict:
|
||||||
|
"""Compute deck statistics from the enriched card list."""
|
||||||
|
by_category = {}
|
||||||
|
by_status = {"owned": 0, "substituted": 0, "missing": 0}
|
||||||
|
total_price = 0.0
|
||||||
|
owned_price = 0.0
|
||||||
|
missing_price = 0.0
|
||||||
|
role_counts = {}
|
||||||
|
|
||||||
|
for card in cards:
|
||||||
|
cat = card.category or "Other"
|
||||||
|
by_category[cat] = by_category.get(cat, 0) + card.count
|
||||||
|
by_status[card.status] = by_status.get(card.status, 0) + 1
|
||||||
|
price = card.estimated_price_usd * card.count
|
||||||
|
total_price += price
|
||||||
|
if card.owned:
|
||||||
|
owned_price += price
|
||||||
|
else:
|
||||||
|
missing_price += price
|
||||||
|
for role in card.role_tags:
|
||||||
|
role_counts[role] = role_counts.get(role, 0) + 1
|
||||||
|
|
||||||
|
land_count = by_category.get("Land", 0)
|
||||||
|
nonland_count = sum(v for k, v in by_category.items() if k != "Land")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_cards": sum(c.count for c in cards),
|
||||||
|
"land_count": land_count,
|
||||||
|
"nonland_count": nonland_count,
|
||||||
|
"by_category": by_category,
|
||||||
|
"by_status": by_status,
|
||||||
|
"role_counts": role_counts,
|
||||||
|
"total_price_usd": round(total_price, 2),
|
||||||
|
"owned_price_usd": round(owned_price, 2),
|
||||||
|
"missing_price_usd": round(missing_price, 2),
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"deckId": "Counterpunch",
|
|
||||||
"commander": "Counterpunch",
|
|
||||||
"colorIdentity": [],
|
|
||||||
"meta": {
|
|
||||||
"gamesSimulated": 2,
|
|
||||||
"overallWinRate": 0.0,
|
|
||||||
"avgGameLength": 15.5,
|
|
||||||
"perArchetypeWinRates": {}
|
|
||||||
},
|
|
||||||
"matchups": [
|
|
||||||
{
|
|
||||||
"opponentDeck": "Devour_for_Power_CMD",
|
|
||||||
"opponentCommander": "",
|
|
||||||
"gamesPlayed": 2,
|
|
||||||
"winRate": 0.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"opponentDeck": "Heavenly_Inferno_CMD",
|
|
||||||
"opponentCommander": "",
|
|
||||||
"gamesPlayed": 2,
|
|
||||||
"winRate": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"structure": {
|
|
||||||
"landCount": 0,
|
|
||||||
"curveBuckets": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"cardTypeCounts": {},
|
|
||||||
"functionalCounts": {}
|
|
||||||
},
|
|
||||||
"cards": [],
|
|
||||||
"underperformers": [],
|
|
||||||
"overperformers": [],
|
|
||||||
"knownCombos": [],
|
|
||||||
"lastUpdated": "2026-03-10T15:39:39.798151+00:00"
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"deckId": "Devour_for_Power_CMD",
|
|
||||||
"commander": "Devour_for_Power_CMD",
|
|
||||||
"colorIdentity": [],
|
|
||||||
"meta": {
|
|
||||||
"gamesSimulated": 2,
|
|
||||||
"overallWinRate": 0.0,
|
|
||||||
"avgGameLength": 15.5,
|
|
||||||
"perArchetypeWinRates": {}
|
|
||||||
},
|
|
||||||
"matchups": [
|
|
||||||
{
|
|
||||||
"opponentDeck": "Counterpunch",
|
|
||||||
"opponentCommander": "",
|
|
||||||
"gamesPlayed": 2,
|
|
||||||
"winRate": 0.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"opponentDeck": "Heavenly_Inferno_CMD",
|
|
||||||
"opponentCommander": "",
|
|
||||||
"gamesPlayed": 2,
|
|
||||||
"winRate": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"structure": {
|
|
||||||
"landCount": 0,
|
|
||||||
"curveBuckets": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"cardTypeCounts": {},
|
|
||||||
"functionalCounts": {}
|
|
||||||
},
|
|
||||||
"cards": [
|
|
||||||
{
|
|
||||||
"name": "a card",
|
|
||||||
"drawnRate": 1.0,
|
|
||||||
"castRate": 0.0,
|
|
||||||
"keptInOpeningHandRate": 0.0,
|
|
||||||
"deadCardRate": 1.0,
|
|
||||||
"impactScore": 0.0,
|
|
||||||
"synergyScore": 0.0,
|
|
||||||
"clunkinessScore": 1.0,
|
|
||||||
"avgTurnCast": null,
|
|
||||||
"avgDamageDealt": 0.0,
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"underperformers": [],
|
|
||||||
"overperformers": [],
|
|
||||||
"knownCombos": [],
|
|
||||||
"lastUpdated": "2026-03-10T15:39:39.799039+00:00"
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"deckId": "Heavenly_Inferno_CMD",
|
|
||||||
"commander": "Heavenly_Inferno_CMD",
|
|
||||||
"colorIdentity": [],
|
|
||||||
"meta": {
|
|
||||||
"gamesSimulated": 2,
|
|
||||||
"overallWinRate": 1.0,
|
|
||||||
"avgGameLength": 15.5,
|
|
||||||
"perArchetypeWinRates": {}
|
|
||||||
},
|
|
||||||
"matchups": [
|
|
||||||
{
|
|
||||||
"opponentDeck": "Counterpunch",
|
|
||||||
"opponentCommander": "",
|
|
||||||
"gamesPlayed": 2,
|
|
||||||
"winRate": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"opponentDeck": "Devour_for_Power_CMD",
|
|
||||||
"opponentCommander": "",
|
|
||||||
"gamesPlayed": 2,
|
|
||||||
"winRate": 1.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"structure": {
|
|
||||||
"landCount": 0,
|
|
||||||
"curveBuckets": [
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"cardTypeCounts": {},
|
|
||||||
"functionalCounts": {}
|
|
||||||
},
|
|
||||||
"cards": [
|
|
||||||
{
|
|
||||||
"name": "zero cards",
|
|
||||||
"drawnRate": 1.0,
|
|
||||||
"castRate": 0.0,
|
|
||||||
"keptInOpeningHandRate": 0.0,
|
|
||||||
"deadCardRate": 1.0,
|
|
||||||
"impactScore": -0.0,
|
|
||||||
"synergyScore": 0.0,
|
|
||||||
"clunkinessScore": 1.0,
|
|
||||||
"avgTurnCast": null,
|
|
||||||
"avgDamageDealt": 0.0,
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"underperformers": [],
|
|
||||||
"overperformers": [],
|
|
||||||
"knownCombos": [],
|
|
||||||
"lastUpdated": "2026-03-10T15:39:39.799775+00:00"
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,77 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
<groupId>com.commanderailab</groupId>
|
|
||||||
<artifactId>commander-ai-lab</artifactId>
|
|
||||||
<name>Commander AI Lab</name>
|
|
||||||
<version>1.0.0-SNAPSHOT</version>
|
|
||||||
<description>Headless batch simulation environment for MTG Commander with 3-AI pods</description>
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<artifactId>maven-jar-plugin</artifactId>
|
|
||||||
<version>3.3.0</version>
|
|
||||||
<configuration>
|
|
||||||
<archive>
|
|
||||||
<manifest>
|
|
||||||
<mainClass>commanderailab.cli.LabCli</mainClass>
|
|
||||||
</manifest>
|
|
||||||
</archive>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
<plugin>
|
|
||||||
<artifactId>maven-shade-plugin</artifactId>
|
|
||||||
<version>3.5.1</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<phase>package</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>shade</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<transformers>
|
|
||||||
<transformer>
|
|
||||||
<mainClass>commanderailab.cli.LabCli</mainClass>
|
|
||||||
</transformer>
|
|
||||||
</transformers>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
<plugin>
|
|
||||||
<artifactId>maven-surefire-plugin</artifactId>
|
|
||||||
<version>3.2.3</version>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
<version>5.10.2</version>
|
|
||||||
<scope>test</scope>
|
|
||||||
<exclusions>
|
|
||||||
<exclusion>
|
|
||||||
<artifactId>junit-jupiter-api</artifactId>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
</exclusion>
|
|
||||||
<exclusion>
|
|
||||||
<artifactId>junit-jupiter-params</artifactId>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
</exclusion>
|
|
||||||
<exclusion>
|
|
||||||
<artifactId>junit-jupiter-engine</artifactId>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
</exclusion>
|
|
||||||
</exclusions>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
<properties>
|
|
||||||
<maven.compiler.source>17</maven.compiler.source>
|
|
||||||
<maven.compiler.target>17</maven.compiler.target>
|
|
||||||
<picocli.version>4.7.5</picocli.version>
|
|
||||||
<gson.version>2.10.1</gson.version>
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
||||||
<forge.version>2.0.12-SNAPSHOT</forge.version>
|
|
||||||
</properties>
|
|
||||||
</project>
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>commander-ai-lab-ui</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+4266
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "commander-ai-lab-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
|
"tailwindcss": "^4.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { PageLayout } from './components/layout/PageLayout'
|
||||||
|
import { BatchSimPage } from './pages/BatchSimPage'
|
||||||
|
import { CollectionPage } from './pages/CollectionPage'
|
||||||
|
import { DecksPage } from './pages/DecksPage'
|
||||||
|
import { AutoGenPage } from './pages/AutoGenPage'
|
||||||
|
import { SimulatorPage } from './pages/SimulatorPage'
|
||||||
|
import { CoachPage } from './pages/CoachPage'
|
||||||
|
import { TrainingPage } from './pages/TrainingPage'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<PageLayout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<BatchSimPage />} />
|
||||||
|
<Route path="/collection" element={<CollectionPage />} />
|
||||||
|
<Route path="/decks" element={<DecksPage />} />
|
||||||
|
<Route path="/autogen" element={<AutoGenPage />} />
|
||||||
|
<Route path="/simulator" element={<SimulatorPage />} />
|
||||||
|
<Route path="/coach" element={<CoachPage />} />
|
||||||
|
<Route path="/training" element={<TrainingPage />} />
|
||||||
|
</Routes>
|
||||||
|
</PageLayout>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Base API client — all fetch calls go through here
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const BASE = '' // same origin; Vite proxy handles /api in dev
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResponse<T>(res: Response): Promise<T> {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text()
|
||||||
|
let message = `HTTP ${res.status}`
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(body)
|
||||||
|
message = json.detail || json.error || message
|
||||||
|
} catch {
|
||||||
|
if (body) message = body
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, message)
|
||||||
|
}
|
||||||
|
// Handle 204 No Content
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||||
|
const url = new URL(BASE + path, window.location.origin)
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== '') url.searchParams.set(k, String(v))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const res = await fetch(url.toString())
|
||||||
|
return handleResponse<T>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function post<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(BASE + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
return handleResponse<T>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postForm<T>(path: string, formData: FormData): Promise<T> {
|
||||||
|
const res = await fetch(BASE + path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
return handleResponse<T>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function put<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(BASE + path, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
return handleResponse<T>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patch<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(BASE + path, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
return handleResponse<T>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function del<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(BASE + path, { method: 'DELETE' })
|
||||||
|
return handleResponse<T>(res)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { get, post } from './client'
|
||||||
|
import type { CoachStatus, CoachDeck, CoachSession } from '../types'
|
||||||
|
|
||||||
|
export async function getCoachStatus() {
|
||||||
|
return get<CoachStatus>('/api/coach/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCoachDecks() {
|
||||||
|
return get<CoachDeck[]>('/api/coach/decks')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCoachDeckReport(deckId: number) {
|
||||||
|
return get<Record<string, unknown>>(`/api/coach/decks/${deckId}/report`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeCoachDeck(deckId: number) {
|
||||||
|
return post<Record<string, unknown>>(`/api/coach/decks/${deckId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCoachSessions() {
|
||||||
|
return get<CoachSession[]>('/api/coach/sessions')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCoachSession(sessionId: string) {
|
||||||
|
return get<CoachSession>(`/api/coach/sessions/${sessionId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadEmbeddings() {
|
||||||
|
return post<{ status: string }>('/api/coach/embeddings/download')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEmbeddings(q: string, topK?: number) {
|
||||||
|
return get<Array<{ name: string; score: number }>>('/api/coach/embeddings/search', { q, top_k: topK })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateReport(deckId: number) {
|
||||||
|
return post<{ report_id: string }>('/api/coach/reports/generate', { deck_id: deckId })
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { get, post, patch, postForm } from './client'
|
||||||
|
import type { CollectionCard, CollectionFilters, SetInfo, ScanResult } from '../types'
|
||||||
|
|
||||||
|
export async function searchCollection(filters: CollectionFilters) {
|
||||||
|
return get<{ cards: CollectionCard[]; total: number; page: number; page_size: number }>(
|
||||||
|
'/api/collection', filters as Record<string, string | number | boolean>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCard(cardId: number) {
|
||||||
|
return get<CollectionCard>(`/api/collection/${cardId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCard(cardId: number, updates: Partial<CollectionCard>) {
|
||||||
|
return patch<CollectionCard>(`/api/collection/${cardId}`, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSets() {
|
||||||
|
return get<SetInfo[]>('/api/collection/sets')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKeywords() {
|
||||||
|
return get<string[]>('/api/collection/keywords')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportCollection(format: string = 'csv') {
|
||||||
|
const res = await fetch(`/api/collection/export?format=${format}`)
|
||||||
|
return res.blob()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importCollection(file: File) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
return postForm<{ imported: number; errors: string[] }>('/api/collection/import', fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanCard(file: File) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('image', file)
|
||||||
|
return postForm<ScanResult>('/api/collection/scan', fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addScanResult(match: { name: string; set_code: string; scryfall_id: string; quantity?: number }) {
|
||||||
|
return post<{ success: boolean; card_id: number }>('/api/collection/scan/add', match)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reEnrichCards() {
|
||||||
|
return post<{ updated: number }>('/api/collection/re-enrich')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoClassify(cardId: number) {
|
||||||
|
return post<{ classifications: string[] }>(`/api/collection/auto-classify`, { card_id: cardId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoClassifyAll() {
|
||||||
|
return post<{ processed: number }>('/api/collection/auto-classify-all')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEdhrecData(cardId: number) {
|
||||||
|
return get<Record<string, unknown>>(`/api/collection/${cardId}/edhrec`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { get, post } from './client'
|
||||||
|
import type { DeckGenV3Request, DeckGenV3Status, DeckGenV3Result, CommanderSearchResult } from '../types'
|
||||||
|
|
||||||
|
export async function getV3Status() {
|
||||||
|
return get<DeckGenV3Status>('/api/deck/v3/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCommanders(q: string) {
|
||||||
|
return get<CommanderSearchResult[]>('/api/deck-generator/commander-search', { q })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateDeckV3(req: DeckGenV3Request) {
|
||||||
|
return post<DeckGenV3Result>('/api/deck/v3/generate', req)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitDeckV3(req: DeckGenV3Request) {
|
||||||
|
return post<DeckGenV3Result & { deck_id: number; dck_path: string }>('/api/deck/v3/commit', req)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportDeckV3(format: 'csv' | 'dck' | 'moxfield' | 'shopping', req: DeckGenV3Request) {
|
||||||
|
return post<Record<string, unknown>>(`/api/deck/v3/export/${format}`, req)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { get, post, put, del, patch } from './client'
|
||||||
|
import type { Deck, DeckCard, DeckAnalysis, DeckRecommendation, PplxStatus } from '../types'
|
||||||
|
|
||||||
|
export async function listDecks() {
|
||||||
|
return get<Deck[]>('/api/decks')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeck(deckId: number) {
|
||||||
|
return get<Deck>(`/api/decks/${deckId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDeck(deck: { name: string; commander?: string; format?: string }) {
|
||||||
|
return post<Deck>('/api/decks', deck)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDeck(deckId: number, updates: Partial<Deck>) {
|
||||||
|
return put<Deck>(`/api/decks/${deckId}`, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDeck(deckId: number) {
|
||||||
|
return del<void>(`/api/decks/${deckId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllDecks() {
|
||||||
|
return del<void>('/api/decks')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeckCards(deckId: number) {
|
||||||
|
return get<DeckCard[]>(`/api/decks/${deckId}/cards`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addCardToDeck(deckId: number, card: { name: string; scryfall_id?: string; quantity?: number; category?: string }) {
|
||||||
|
return post<DeckCard>(`/api/decks/${deckId}/cards`, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCardFromDeck(deckId: number, cardId: number) {
|
||||||
|
return del<void>(`/api/decks/${deckId}/cards/${cardId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDeckCard(deckId: number, cardId: number, updates: Partial<DeckCard>) {
|
||||||
|
return patch<DeckCard>(`/api/decks/${deckId}/cards/${cardId}`, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeckAnalysis(deckId: number) {
|
||||||
|
return get<DeckAnalysis>(`/api/decks/${deckId}/analysis`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCollectionRecs(deckId: number) {
|
||||||
|
return get<DeckRecommendation[]>(`/api/decks/${deckId}/recommended-from-collection`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEdhRecs(deckId: number) {
|
||||||
|
return get<DeckRecommendation[]>(`/api/decks/${deckId}/edh-recs`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkAddCards(deckId: number, cards: { name: string; quantity?: number }[]) {
|
||||||
|
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add`, { cards })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkAddRecommended(deckId: number, cards: string[]) {
|
||||||
|
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add-recommended`, { card_names: cards })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportDeckToSim(deckId: number) {
|
||||||
|
return post<{ path: string }>(`/api/decks/${deckId}/export-to-sim`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importDeckToDeck(deckId: number, data: { text?: string; url?: string }) {
|
||||||
|
return post<{ imported: number }>(`/api/decks/${deckId}/import`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importNewDeck(data: { text?: string; url?: string; name?: string }) {
|
||||||
|
return post<Deck>('/api/decks/import-new', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPplxStatus() {
|
||||||
|
return get<PplxStatus>('/api/pplx/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deckResearch(payload: { commander: string; strategy?: string }) {
|
||||||
|
return post<{ research: string }>('/api/deck-research', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deckGenerate(payload: { commander: string; strategy?: string; bracket?: number }) {
|
||||||
|
return post<{ cards: Array<{ name: string; quantity: number; category: string }> }>('/api/deck-generate', payload)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export * as collectionApi from './collection'
|
||||||
|
export * as decksApi from './decks'
|
||||||
|
export * as deckgenApi from './deckgen'
|
||||||
|
export * as labApi from './lab'
|
||||||
|
export * as simApi from './sim'
|
||||||
|
export * as coachApi from './coach'
|
||||||
|
export * as trainingApi from './training'
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { get, post } from './client'
|
||||||
|
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
|
||||||
|
|
||||||
|
export async function getLabDecks() {
|
||||||
|
return get<LabDeck[]>('/api/lab/decks')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startBatchSim(config: { decks: string[]; games: number; threads?: number; use_deepseek?: boolean; deepseek_deck?: string }) {
|
||||||
|
return post<{ run_id: string }>('/api/lab/start', config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startDeepSeekSim(config: { decks: string[]; games: number; deepseek_deck: string }) {
|
||||||
|
return post<{ run_id: string }>('/api/lab/start-deepseek', config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLabStatus() {
|
||||||
|
return get<LabStatus>('/api/lab/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLabResult() {
|
||||||
|
return get<LabResult>('/api/lab/result')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLabHistory() {
|
||||||
|
return get<LabHistoryEntry[]>('/api/lab/history')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPrecons() {
|
||||||
|
return get<PreconDeck[]>('/api/lab/precons')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installPrecon(name: string) {
|
||||||
|
return post<{ installed: boolean }>('/api/lab/precons/install', { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installPreconBatch(names: string[]) {
|
||||||
|
return post<{ installed: number }>('/api/lab/precons/install-batch', { names })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMetaCommanders() {
|
||||||
|
return get<Array<{ name: string; color_identity: string[] }>>('/api/lab/meta/commanders')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchMetaCommanders(q: string) {
|
||||||
|
return get<Array<{ name: string; color_identity: string[]; source: string }>>('/api/lab/meta/search', { q })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMetaDeck(commander: string) {
|
||||||
|
return post<{ deck_name: string; cards: Array<{ name: string; quantity: number }> }>('/api/lab/meta/fetch', { commander })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importDeckFromUrl(url: string) {
|
||||||
|
return post<{ deck_name: string }>('/api/lab/import/url', { url })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importDeckFromText(text: string, name?: string) {
|
||||||
|
return post<{ deck_name: string }>('/api/lab/import/text', { text, name })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLabLog() {
|
||||||
|
return get<{ log: string }>('/api/lab/log')
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { get, post } from './client'
|
||||||
|
import type { SimStatus, SimResult, DeepSeekStatus } from '../types'
|
||||||
|
|
||||||
|
export async function runSim(config: { deck_a: string; deck_b: string; games: number }) {
|
||||||
|
return post<{ sim_id: string }>('/api/sim/run', config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSimFromDeck(deckId: number, config: { opponent?: string; games?: number }) {
|
||||||
|
return post<{ sim_id: string }>('/api/sim/run-from-deck', { deck_id: deckId, ...config })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDeepSeekSim(config: { deck_a: string; deck_b: string; games: number; deepseek_deck: string }) {
|
||||||
|
return post<{ sim_id: string }>('/api/sim/run-deepseek', config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSimStatus(simId: string) {
|
||||||
|
return get<SimStatus>('/api/sim/status', { simId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSimResult(simId: string) {
|
||||||
|
return get<SimResult>('/api/sim/result', { simId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectDeepSeek() {
|
||||||
|
return post<DeepSeekStatus>('/api/deepseek/connect')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeepSeekStatus() {
|
||||||
|
return get<DeepSeekStatus>('/api/deepseek/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function configureDeepSeek(config: { model?: string; base_url?: string }) {
|
||||||
|
return post<DeepSeekStatus>('/api/deepseek/configure', config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeepSeekLogs() {
|
||||||
|
return get<Array<{ filename: string; size: number; modified: string }>>('/api/deepseek/logs')
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { get, post } from './client'
|
||||||
|
import type { MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus, TournamentStatus, TournamentResults } from '../types'
|
||||||
|
|
||||||
|
export async function getMLStatus() {
|
||||||
|
return get<MLStatus>('/api/ml/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleML() {
|
||||||
|
return post<{ enabled: boolean }>('/api/ml/toggle')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDecisionFile(filename: string) {
|
||||||
|
return get<Record<string, unknown>>(`/api/ml/decisions/${filename}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function predict(state: Record<string, unknown>) {
|
||||||
|
return post<{ action: string; confidence: number }>('/api/ml/predict', state)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reloadModel() {
|
||||||
|
return post<{ loaded: boolean }>('/api/ml/reload')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModelInfo() {
|
||||||
|
return get<Record<string, unknown>>('/api/ml/model')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startTraining(config?: { epochs?: number; lr?: number }) {
|
||||||
|
return post<{ started: boolean }>('/api/ml/train', config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrainStatus() {
|
||||||
|
return get<MLTrainStatus>('/api/ml/train/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDataStatus() {
|
||||||
|
return get<MLDataStatus>('/api/ml/data/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startPPOTraining(config?: { iterations?: number }) {
|
||||||
|
return post<{ started: boolean }>('/api/ml/train/ppo', config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPPOStatus() {
|
||||||
|
return get<PPOTrainStatus>('/api/ml/train/ppo/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startTournament(config?: { games?: number }) {
|
||||||
|
return post<{ started: boolean }>('/api/ml/tournament', config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTournamentStatus() {
|
||||||
|
return get<TournamentStatus>('/api/ml/tournament/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTournamentResults() {
|
||||||
|
return get<TournamentResults>('/api/ml/tournament/results')
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface CardImageProps {
|
||||||
|
src: string
|
||||||
|
alt: string
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
className?: string
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZES = {
|
||||||
|
sm: 'w-16 h-[22px]', // tiny inline
|
||||||
|
md: 'w-[146px] h-[204px]', // grid thumbnail
|
||||||
|
lg: 'w-[244px] h-[340px]', // detail view
|
||||||
|
xl: 'w-[336px] h-[468px]', // full size
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardImage({ src, alt, size = 'md', className = '', onClick }: CardImageProps) {
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
// Build Scryfall URL if needed — ensure we get the right size
|
||||||
|
const imgUrl = src || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative overflow-hidden rounded-lg bg-bg-tertiary ${SIZES[size]} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-accent-blue/50 transition-all' : ''} ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{!error ? (
|
||||||
|
<img
|
||||||
|
src={imgUrl}
|
||||||
|
alt={alt}
|
||||||
|
className={`w-full h-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center w-full h-full text-text-tertiary text-xs text-center p-2">
|
||||||
|
{alt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loaded && !error && (
|
||||||
|
<div className="absolute inset-0 bg-bg-tertiary animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon: LucideIcon
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<Icon className="w-12 h-12 text-text-tertiary mb-4" strokeWidth={1.5} />
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mb-1">{title}</h3>
|
||||||
|
{description && <p className="text-sm text-text-secondary max-w-md mb-4">{description}</p>}
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Parse MTG mana cost strings like "{2}{W}{U}" into colored symbols
|
||||||
|
|
||||||
|
const MANA_COLORS: Record<string, string> = {
|
||||||
|
W: 'bg-mana-white text-black',
|
||||||
|
U: 'bg-mana-blue text-white',
|
||||||
|
B: 'bg-mana-black text-white',
|
||||||
|
R: 'bg-mana-red text-white',
|
||||||
|
G: 'bg-mana-green text-white',
|
||||||
|
C: 'bg-mana-colorless text-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManaSymbolsProps {
|
||||||
|
cost: string
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManaSymbols({ cost, size = 'sm' }: ManaSymbolsProps) {
|
||||||
|
if (!cost) return null
|
||||||
|
|
||||||
|
const symbols = cost.match(/\{([^}]+)\}/g)
|
||||||
|
if (!symbols) return <span className="text-text-tertiary">{cost}</span>
|
||||||
|
|
||||||
|
const sz = size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
{symbols.map((s, i) => {
|
||||||
|
const val = s.replace(/[{}]/g, '')
|
||||||
|
const colorClass = MANA_COLORS[val] || 'bg-mana-colorless text-white'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`${sz} inline-flex items-center justify-center rounded-full font-bold ${colorClass}`}
|
||||||
|
>
|
||||||
|
{val}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color identity dots
|
||||||
|
interface ColorDotsProps {
|
||||||
|
colors: string[]
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorDots({ colors, size = 'sm' }: ColorDotsProps) {
|
||||||
|
if (!colors || colors.length === 0) return <span className="text-text-tertiary text-xs">Colorless</span>
|
||||||
|
|
||||||
|
const sz = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'
|
||||||
|
|
||||||
|
const DOT_COLORS: Record<string, string> = {
|
||||||
|
W: 'bg-mana-white',
|
||||||
|
U: 'bg-mana-blue',
|
||||||
|
B: 'bg-mana-black border border-border-secondary',
|
||||||
|
R: 'bg-mana-red',
|
||||||
|
G: 'bg-mana-green',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
{colors.map(c => (
|
||||||
|
<span key={c} className={`${sz} rounded-full ${DOT_COLORS[c] || 'bg-mana-colorless'}`} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
interface SpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZES = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-8 h-8' }
|
||||||
|
|
||||||
|
export function Spinner({ size = 'md', className = '' }: SpinnerProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`animate-spin ${SIZES[size]} ${className}`}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12" cy="12" r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="opacity-20"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 2a10 10 0 0 1 10 10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageSpinner() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner size="lg" className="text-accent-blue" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
|
||||||
|
|
||||||
|
type Variant = 'success' | 'error' | 'warning' | 'info' | 'neutral'
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
variant: Variant
|
||||||
|
label: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLES: Record<Variant, string> = {
|
||||||
|
success: 'bg-status-success/15 text-status-success border-status-success/30',
|
||||||
|
error: 'bg-status-error/15 text-status-error border-status-error/30',
|
||||||
|
warning: 'bg-status-warning/15 text-status-warning border-status-warning/30',
|
||||||
|
info: 'bg-status-info/15 text-status-info border-status-info/30',
|
||||||
|
neutral: 'bg-bg-tertiary text-text-secondary border-border-primary',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS: Record<Variant, typeof CheckCircle> = {
|
||||||
|
success: CheckCircle,
|
||||||
|
error: XCircle,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
info: Info,
|
||||||
|
neutral: Info,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ variant, label, className = '' }: StatusBadgeProps) {
|
||||||
|
const Icon = ICONS[variant]
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md border ${STYLES[variant]} ${className}`}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { Spinner, PageSpinner } from './Spinner'
|
||||||
|
export { StatusBadge } from './StatusBadge'
|
||||||
|
export { CardImage } from './CardImage'
|
||||||
|
export { ManaSymbols, ColorDots } from './ManaSymbols'
|
||||||
|
export { EmptyState } from './EmptyState'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Sidebar } from './Sidebar'
|
||||||
|
|
||||||
|
interface PageLayoutProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageLayout({ children }: PageLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg-primary">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="ml-56 min-h-screen">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ to: '/', label: 'Batch Sim', icon: '⚗️' },
|
||||||
|
{ to: '/collection', label: 'Collection', icon: '📦' },
|
||||||
|
{ to: '/decks', label: 'Decks', icon: '🃏' },
|
||||||
|
{ to: '/autogen', label: 'Auto Gen', icon: '⚡' },
|
||||||
|
{ to: '/simulator', label: 'Simulator', icon: '🎮' },
|
||||||
|
{ to: '/coach', label: 'Coach', icon: '🧠' },
|
||||||
|
{ to: '/training', label: 'Training', icon: '📈' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
return (
|
||||||
|
<aside className="fixed left-0 top-0 bottom-0 w-56 bg-bg-secondary border-r border-border-primary flex flex-col z-30">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="px-5 py-5 border-b border-border-primary">
|
||||||
|
<h1 className="text-base font-bold text-text-primary tracking-tight">
|
||||||
|
Commander AI Lab
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-text-tertiary mt-0.5">MTG Intelligence Platform</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 py-3 overflow-y-auto">
|
||||||
|
{NAV_ITEMS.map(item => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-5 py-2.5 text-sm transition-colors duration-150 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-accent-blue/10 text-accent-blue border-r-2 border-accent-blue'
|
||||||
|
: 'text-text-secondary hover:text-text-primary hover:bg-bg-hover'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-base">{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-5 py-4 border-t border-border-primary">
|
||||||
|
<p className="text-xs text-text-tertiary">v4.0 — React + TypeScript</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
/** Polls a function at the given interval while `enabled` is true */
|
||||||
|
export function usePolling(fn: () => void, intervalMs: number, enabled: boolean) {
|
||||||
|
const savedFn = useRef(fn)
|
||||||
|
savedFn.current = fn
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
const id = setInterval(() => savedFn.current(), intervalMs)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [intervalMs, enabled])
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════
|
||||||
|
Commander AI Lab — Design Tokens (Tailwind v4 CSS Theme)
|
||||||
|
Fresh dark theme with MTG-inspired accents
|
||||||
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* ── Backgrounds ── */
|
||||||
|
--color-bg-primary: #0f1117;
|
||||||
|
--color-bg-secondary: #161921;
|
||||||
|
--color-bg-tertiary: #1c2030;
|
||||||
|
--color-bg-elevated: #222738;
|
||||||
|
--color-bg-hover: #2a3048;
|
||||||
|
|
||||||
|
/* ── Borders ── */
|
||||||
|
--color-border-primary: #2a2f42;
|
||||||
|
--color-border-secondary: #363c54;
|
||||||
|
--color-border-accent: #4f6ef7;
|
||||||
|
|
||||||
|
/* ── Text ── */
|
||||||
|
--color-text-primary: #e8eaf0;
|
||||||
|
--color-text-secondary: #9ba1b8;
|
||||||
|
--color-text-tertiary: #6b7190;
|
||||||
|
--color-text-inverse: #0f1117;
|
||||||
|
|
||||||
|
/* ── Accents ── */
|
||||||
|
--color-accent-blue: #4f6ef7;
|
||||||
|
--color-accent-blue-hover: #6b84ff;
|
||||||
|
--color-accent-teal: #2dd4bf;
|
||||||
|
--color-accent-teal-hover: #5eead4;
|
||||||
|
--color-accent-purple: #a78bfa;
|
||||||
|
--color-accent-amber: #fbbf24;
|
||||||
|
--color-accent-red: #f87171;
|
||||||
|
--color-accent-green: #4ade80;
|
||||||
|
|
||||||
|
/* ── MTG Mana Colors ── */
|
||||||
|
--color-mana-white: #f9faf4;
|
||||||
|
--color-mana-blue: #0e67ab;
|
||||||
|
--color-mana-black: #2b2424;
|
||||||
|
--color-mana-red: #d3202a;
|
||||||
|
--color-mana-green: #00733e;
|
||||||
|
--color-mana-colorless: #706f6f;
|
||||||
|
|
||||||
|
/* ── Status ── */
|
||||||
|
--color-status-success: #4ade80;
|
||||||
|
--color-status-warning: #fbbf24;
|
||||||
|
--color-status-error: #f87171;
|
||||||
|
--color-status-info: #60a5fa;
|
||||||
|
|
||||||
|
/* ── Radius ── */
|
||||||
|
--radius-sm: 0.375rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
--radius-xl: 1rem;
|
||||||
|
|
||||||
|
/* ── Shadows ── */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-glow-blue: 0 0 20px rgba(79, 110, 247, 0.15);
|
||||||
|
--shadow-glow-teal: 0 0 20px rgba(45, 212, 191, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base Styles ── */
|
||||||
|
html {
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ── */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Utility animations ── */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(79, 110, 247, 0.2); }
|
||||||
|
50% { box-shadow: 0 0 16px 4px rgba(79, 110, 247, 0.15); }
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.animate-pulse-glow {
|
||||||
|
animation: pulse-glow 2s infinite;
|
||||||
|
}
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
Search, Wand2, Save, AlertCircle,
|
||||||
|
ChevronDown, Sparkles, RefreshCw, Check
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Spinner, StatusBadge, ManaSymbols, CardImage, ColorDots } from '../components/common'
|
||||||
|
import { deckgenApi } from '../api'
|
||||||
|
import type { DeckGenV3Status, DeckGenV3Result, DeckGenV3Card, CommanderSearchResult } from '../types'
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||||
|
|
||||||
|
// ── Commander Search ──────────────────────────────────────────
|
||||||
|
function CommanderSearch({ onSelect }: { onSelect: (c: CommanderSearchResult) => void }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<CommanderSearchResult[]>([])
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
||||||
|
function handleSearch(q: string) {
|
||||||
|
setQuery(q)
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
if (q.length < 2) { setResults([]); return }
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setSearching(true)
|
||||||
|
try {
|
||||||
|
const r = await deckgenApi.searchCommanders(q)
|
||||||
|
setResults(r)
|
||||||
|
} catch { setResults([]) }
|
||||||
|
setSearching(false)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search for a commander..."
|
||||||
|
value={query}
|
||||||
|
onChange={e => handleSearch(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
|
||||||
|
/>
|
||||||
|
{searching && <Spinner size="sm" className="absolute right-3 top-1/2 -translate-y-1/2 text-accent-blue" />}
|
||||||
|
</div>
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="absolute z-20 top-full mt-1 w-full bg-bg-elevated border border-border-primary rounded-xl shadow-lg max-h-80 overflow-y-auto">
|
||||||
|
{results.map(c => (
|
||||||
|
<button
|
||||||
|
key={c.scryfall_id}
|
||||||
|
onClick={() => { onSelect(c); setQuery(c.name); setResults([]) }}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-bg-hover text-left transition-colors"
|
||||||
|
>
|
||||||
|
<CardImage src={c.image_url} alt={c.name} size="sm" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-primary truncate">{c.name}</p>
|
||||||
|
<p className="text-xs text-text-secondary truncate">{c.type_line}</p>
|
||||||
|
</div>
|
||||||
|
<ColorDots colors={c.color_identity} />
|
||||||
|
{c.in_collection && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 bg-accent-green/15 text-accent-green rounded">Owned</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generated Deck View ───────────────────────────────────────
|
||||||
|
function GeneratedDeckView({ result, onCommit }: { result: DeckGenV3Result; onCommit: () => void }) {
|
||||||
|
const [view, setView] = useState<'visual' | 'list'>('visual')
|
||||||
|
const [committing, setCommitting] = useState(false)
|
||||||
|
const [selectedCard, setSelectedCard] = useState<DeckGenV3Card | null>(null)
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const grouped = result.cards.reduce<Record<string, DeckGenV3Card[]>>((acc, c) => {
|
||||||
|
const cat = c.category || 'Other'
|
||||||
|
if (!acc[cat]) acc[cat] = []
|
||||||
|
acc[cat].push(c)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const curveData = Object.entries(result.stats.mana_curve)
|
||||||
|
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
|
||||||
|
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
|
||||||
|
|
||||||
|
async function handleCommit() {
|
||||||
|
setCommitting(true)
|
||||||
|
try { onCommit() } catch { /* ignore */ }
|
||||||
|
setCommitting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 animate-fade-in">
|
||||||
|
{/* Commander header */}
|
||||||
|
<div className="flex gap-5 bg-bg-secondary rounded-xl border border-border-primary p-5">
|
||||||
|
<CardImage src={result.commander.image_url} alt={result.commander.name} size="lg" />
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary">{result.commander.name}</h2>
|
||||||
|
<p className="text-sm text-text-secondary">{result.commander.type_line}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<ManaSymbols cost={result.commander.mana_cost} size="md" />
|
||||||
|
<ColorDots colors={result.commander.color_identity} size="md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-primary whitespace-pre-wrap">{result.commander.oracle_text}</p>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-text-secondary">
|
||||||
|
<span>Strategy: <span className="text-accent-blue font-medium">{result.strategy}</span></span>
|
||||||
|
<span>Bracket: <span className="text-accent-amber font-medium">{result.bracket}</span></span>
|
||||||
|
<span>Model: <span className="text-accent-purple font-medium">{result.model}</span></span>
|
||||||
|
</div>
|
||||||
|
{result.substitution && (
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<span className="text-accent-green">{result.substitution.owned} owned</span>
|
||||||
|
<span className="text-accent-amber">{result.substitution.substituted} substituted</span>
|
||||||
|
<span className="text-accent-red">{result.substitution.missing} missing</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={handleCommit} disabled={committing}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-accent-green/15 text-accent-green text-sm font-medium rounded-lg hover:bg-accent-green/25 border border-accent-green/30 transition-colors disabled:opacity-50">
|
||||||
|
{committing ? <Spinner size="sm" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save to Decks
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
|
||||||
|
<button onClick={() => setView('visual')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'visual' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
|
||||||
|
Visual
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setView('list')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Stats summary */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className="text-xs text-text-tertiary">{result.stats.card_count} cards</span>
|
||||||
|
<span className="text-xs text-accent-teal font-medium">${result.stats.total_price.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mana curve mini */}
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4">
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">Mana Curve</h4>
|
||||||
|
<div className="h-28">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={curveData} barSize={20}>
|
||||||
|
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 10 }} />
|
||||||
|
<YAxis hide />
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 11 }} />
|
||||||
|
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
|
||||||
|
{curveData.map((_, i) => <Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />)}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card grid/list */}
|
||||||
|
{view === 'visual' ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{Object.entries(grouped).map(([cat, cards]) => (
|
||||||
|
<div key={cat}>
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-3">{cat} ({cards.length})</h4>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-2">
|
||||||
|
{cards.map(card => (
|
||||||
|
<div key={card.name} className="group cursor-pointer relative" onClick={() => setSelectedCard(card)}>
|
||||||
|
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
|
||||||
|
{card.is_substitute && (
|
||||||
|
<div className="absolute top-1 right-1 w-4 h-4 bg-accent-amber rounded-full flex items-center justify-center">
|
||||||
|
<RefreshCw className="w-2.5 h-2.5 text-bg-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{card.owned && (
|
||||||
|
<div className="absolute top-1 left-1 w-4 h-4 bg-accent-green rounded-full flex items-center justify-center">
|
||||||
|
<Check className="w-2.5 h-2.5 text-bg-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-primary text-left">
|
||||||
|
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
|
||||||
|
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Category</th>
|
||||||
|
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Role</th>
|
||||||
|
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
|
||||||
|
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
|
||||||
|
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-primary">
|
||||||
|
{result.cards.map(card => (
|
||||||
|
<tr key={card.name} className="hover:bg-bg-hover transition-colors">
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span className="font-medium text-text-primary">{card.quantity}x {card.name}</span>
|
||||||
|
{card.is_substitute && (
|
||||||
|
<span className="ml-2 text-[10px] text-accent-amber">sub for {card.original_name}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.category}</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.role}</td>
|
||||||
|
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-accent-teal text-xs">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span className={`text-[10px] px-2 py-0.5 rounded ${card.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary'}`}>
|
||||||
|
{card.owned ? `Owned (${card.owned_qty})` : 'Missing'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Card detail popup */}
|
||||||
|
{selectedCard && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={() => setSelectedCard(null)}>
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-2xl w-full mx-4 p-6 animate-fade-in" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex gap-5">
|
||||||
|
<CardImage src={selectedCard.image_url} alt={selectedCard.name} size="lg" />
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<h3 className="text-lg font-bold text-text-primary">{selectedCard.name}</h3>
|
||||||
|
<p className="text-sm text-text-secondary">{selectedCard.type_line}</p>
|
||||||
|
<ManaSymbols cost={selectedCard.mana_cost} size="md" />
|
||||||
|
<p className="text-sm text-text-primary whitespace-pre-wrap">{selectedCard.reason}</p>
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<span className="text-text-tertiary">Role: <span className="text-accent-blue">{selectedCard.role}</span></span>
|
||||||
|
{selectedCard.is_substitute && (
|
||||||
|
<span className="text-accent-amber">Substituted for {selectedCard.original_name} ({(selectedCard.similarity_score ?? 0 * 100).toFixed(0)}%)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Auto Gen Page ────────────────────────────────────────
|
||||||
|
export function AutoGenPage() {
|
||||||
|
const [status, setStatus] = useState<DeckGenV3Status | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [result, setResult] = useState<DeckGenV3Result | null>(null)
|
||||||
|
const [commander, setCommander] = useState<CommanderSearchResult | null>(null)
|
||||||
|
const [strategy, setStrategy] = useState('')
|
||||||
|
const [bracket, setBracket] = useState(3)
|
||||||
|
const [budget, setBudget] = useState<string>('')
|
||||||
|
const [useCollection, setUseCollection] = useState(true)
|
||||||
|
const [runSub, setRunSub] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
deckgenApi.getV3Status().then(s => { setStatus(s); setLoading(false) }).catch(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
if (!commander) return
|
||||||
|
setGenerating(true)
|
||||||
|
setError(null)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const res = await deckgenApi.generateDeckV3({
|
||||||
|
commander_name: commander.name,
|
||||||
|
strategy: strategy || undefined,
|
||||||
|
target_bracket: bracket,
|
||||||
|
budget_usd: budget ? Number(budget) : undefined,
|
||||||
|
use_collection: useCollection,
|
||||||
|
run_substitution: runSub,
|
||||||
|
})
|
||||||
|
setResult(res)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Generation failed')
|
||||||
|
}
|
||||||
|
setGenerating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCommit() {
|
||||||
|
if (!commander || !result) return
|
||||||
|
try {
|
||||||
|
await deckgenApi.commitDeckV3({
|
||||||
|
commander_name: commander.name,
|
||||||
|
strategy: strategy || undefined,
|
||||||
|
target_bracket: bracket,
|
||||||
|
budget_usd: budget ? Number(budget) : undefined,
|
||||||
|
use_collection: useCollection,
|
||||||
|
run_substitution: runSub,
|
||||||
|
})
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||||
|
<Sparkles className="w-6 h-6 text-accent-purple" />
|
||||||
|
Auto Gen V3
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-0.5">AI-powered deck generation with Perplexity</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status bar */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusBadge
|
||||||
|
variant={status?.initialized ? 'success' : 'error'}
|
||||||
|
label={status?.initialized ? 'Ready' : 'Not Initialized'}
|
||||||
|
/>
|
||||||
|
<StatusBadge
|
||||||
|
variant={status?.pplx_configured ? 'success' : 'warning'}
|
||||||
|
label={status?.pplx_configured ? 'Perplexity Connected' : 'Perplexity Not Set'}
|
||||||
|
/>
|
||||||
|
{status?.embeddings_loaded && (
|
||||||
|
<StatusBadge variant="info" label={`${status.embedding_cards.toLocaleString()} embeddings`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!result ? (
|
||||||
|
/* Config form */
|
||||||
|
<div className="max-w-2xl space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-text-primary block mb-2">Commander</label>
|
||||||
|
<CommanderSearch onSelect={setCommander} />
|
||||||
|
{commander && (
|
||||||
|
<div className="mt-2 flex items-center gap-3 p-3 bg-bg-secondary rounded-lg border border-border-primary">
|
||||||
|
<CardImage src={commander.image_url} alt={commander.name} size="sm" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-text-primary">{commander.name}</p>
|
||||||
|
<p className="text-xs text-text-secondary">{commander.type_line}</p>
|
||||||
|
</div>
|
||||||
|
<ColorDots colors={commander.color_identity} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-text-primary block mb-2">Strategy (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Voltron, Aristocrats, Storm..."
|
||||||
|
value={strategy}
|
||||||
|
onChange={e => setStrategy(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-text-primary block mb-2">Target Bracket</label>
|
||||||
|
<select
|
||||||
|
value={bracket}
|
||||||
|
onChange={e => setBracket(Number(e.target.value))}
|
||||||
|
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
>
|
||||||
|
<option value={1}>1 — Casual</option>
|
||||||
|
<option value={2}>2 — Low Power</option>
|
||||||
|
<option value={3}>3 — Mid Power</option>
|
||||||
|
<option value={4}>4 — High Power</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-text-primary block mb-2">Budget (USD, optional)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="No limit"
|
||||||
|
value={budget}
|
||||||
|
onChange={e => setBudget(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={useCollection} onChange={e => setUseCollection(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
|
||||||
|
<span className="text-sm text-text-secondary">Use collection cards</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={runSub} onChange={e => setRunSub(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
|
||||||
|
<span className="text-sm text-text-secondary">Run substitution pass</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!commander || generating || !status?.initialized}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-accent-blue text-white text-sm font-semibold rounded-lg hover:bg-accent-blue-hover shadow-glow-blue transition-all disabled:opacity-50 disabled:shadow-none"
|
||||||
|
>
|
||||||
|
{generating ? (
|
||||||
|
<><Spinner size="sm" /> Generating...</>
|
||||||
|
) : (
|
||||||
|
<><Wand2 className="w-4 h-4" /> Generate Deck</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Results */
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setResult(null)}
|
||||||
|
className="flex items-center gap-2 mb-4 text-sm text-text-secondary hover:text-text-primary transition-colors">
|
||||||
|
<ChevronDown className="w-4 h-4 rotate-90" />
|
||||||
|
Back to Config
|
||||||
|
</button>
|
||||||
|
<GeneratedDeckView result={result} onCommit={handleCommit} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
FlaskConical, Play, Clock, Trophy,
|
||||||
|
Upload, Link, FileText, History, ChevronRight, AlertCircle,
|
||||||
|
Check
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Spinner } from '../components/common'
|
||||||
|
import { labApi } from '../api'
|
||||||
|
import { usePolling } from '../hooks/usePolling'
|
||||||
|
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||||
|
|
||||||
|
// ── Results Chart ─────────────────────────────────────────────
|
||||||
|
function ResultsChart({ result }: { result: LabResult }) {
|
||||||
|
const data = result.decks
|
||||||
|
.sort((a, b) => b.win_rate - a.win_rate)
|
||||||
|
.map(d => ({
|
||||||
|
name: d.name.length > 20 ? d.name.slice(0, 18) + '...' : d.name,
|
||||||
|
win_rate: Math.round(d.win_rate * 100),
|
||||||
|
fullName: d.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={data} layout="vertical" barSize={20} margin={{ left: 10, right: 20 }}>
|
||||||
|
<XAxis type="number" domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis type="category" dataKey="name" width={150} tick={{ fill: '#9ba1b8', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
|
||||||
|
formatter={(value: unknown) => [`${value}%`, 'Win Rate']}
|
||||||
|
labelFormatter={(label: unknown) => data.find(d => d.name === String(label))?.fullName || String(label)}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="win_rate" radius={[0, 4, 4, 0]}>
|
||||||
|
{data.map((_, i) => (
|
||||||
|
<Cell key={i} fill={i === 0 ? '#4ade80' : i === 1 ? '#4f6ef7' : '#363c54'} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deck Selector ─────────────────────────────────────────────
|
||||||
|
function DeckSelector({
|
||||||
|
decks,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
precons,
|
||||||
|
onInstallPrecon,
|
||||||
|
}: {
|
||||||
|
decks: LabDeck[]
|
||||||
|
selected: Set<string>
|
||||||
|
onToggle: (name: string) => void
|
||||||
|
precons: PreconDeck[]
|
||||||
|
onInstallPrecon: (name: string) => void
|
||||||
|
}) {
|
||||||
|
const [showPrecons, setShowPrecons] = useState(false)
|
||||||
|
const [installing, setInstalling] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleInstall(name: string) {
|
||||||
|
setInstalling(name)
|
||||||
|
await onInstallPrecon(name)
|
||||||
|
setInstalling(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary">Available Decks</h3>
|
||||||
|
{decks.length === 0 ? (
|
||||||
|
<p className="text-xs text-text-tertiary">No decks available. Import or install precons.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||||
|
{decks.map(d => (
|
||||||
|
<label key={d.name} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover cursor-pointer transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(d.name)}
|
||||||
|
onChange={() => onToggle(d.name)}
|
||||||
|
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm text-text-primary truncate block">{d.name}</span>
|
||||||
|
<span className="text-[10px] text-text-tertiary">{d.source} · {d.card_count} cards</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPrecons(!showPrecons)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium bg-bg-tertiary rounded-lg text-text-secondary hover:bg-bg-hover border border-border-primary transition-colors"
|
||||||
|
>
|
||||||
|
Precon Decks
|
||||||
|
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showPrecons ? 'rotate-90' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showPrecons && (
|
||||||
|
<div className="space-y-1 max-h-48 overflow-y-auto animate-fade-in">
|
||||||
|
{precons.map(p => (
|
||||||
|
<div key={p.name} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-bg-tertiary">
|
||||||
|
<span className="flex-1 text-xs text-text-secondary truncate">{p.name}</span>
|
||||||
|
{p.installed ? (
|
||||||
|
<span className="text-[10px] text-accent-green flex items-center gap-1"><Check className="w-3 h-3" /> Installed</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleInstall(p.name)}
|
||||||
|
disabled={installing === p.name}
|
||||||
|
className="text-[10px] text-accent-blue hover:text-accent-blue-hover transition-colors"
|
||||||
|
>
|
||||||
|
{installing === p.name ? <Spinner size="sm" /> : 'Install'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Import Panel ──────────────────────────────────────────────
|
||||||
|
function ImportPanel({ onImported }: { onImported: () => void }) {
|
||||||
|
const [importUrl, setImportUrl] = useState('')
|
||||||
|
const [importText, setImportText] = useState('')
|
||||||
|
const [importName, setImportName] = useState('')
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [importMode, setImportMode] = useState<'url' | 'text'>('url')
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
setImporting(true)
|
||||||
|
try {
|
||||||
|
if (importMode === 'url' && importUrl.trim()) {
|
||||||
|
await labApi.importDeckFromUrl(importUrl.trim())
|
||||||
|
} else if (importMode === 'text' && importText.trim()) {
|
||||||
|
await labApi.importDeckFromText(importText.trim(), importName || undefined)
|
||||||
|
}
|
||||||
|
setImportUrl('')
|
||||||
|
setImportText('')
|
||||||
|
setImportName('')
|
||||||
|
onImported()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<Upload className="w-4 h-4 text-accent-blue" />
|
||||||
|
Import Deck
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => setImportMode('url')}
|
||||||
|
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'url' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
|
||||||
|
<Link className="w-3 h-3 inline mr-1" />URL
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setImportMode('text')}
|
||||||
|
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'text' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
|
||||||
|
<FileText className="w-3 h-3 inline mr-1" />Text
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{importMode === 'url' ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Moxfield, Archidekt, or deck URL..."
|
||||||
|
value={importUrl}
|
||||||
|
onChange={e => setImportUrl(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Deck name"
|
||||||
|
value={importName}
|
||||||
|
onChange={e => setImportName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder={"1x Sol Ring\n1x Command Tower\n..."}
|
||||||
|
value={importText}
|
||||||
|
onChange={e => setImportText(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button onClick={handleImport} disabled={importing}
|
||||||
|
className="w-full px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
|
||||||
|
{importing ? <Spinner size="sm" /> : 'Import'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Batch Sim Page ───────────────────────────────────────
|
||||||
|
export function BatchSimPage() {
|
||||||
|
const [decks, setDecks] = useState<LabDeck[]>([])
|
||||||
|
const [precons, setPrecons] = useState<PreconDeck[]>([])
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [games, setGames] = useState(100)
|
||||||
|
const [status, setStatus] = useState<LabStatus | null>(null)
|
||||||
|
const [result, setResult] = useState<LabResult | null>(null)
|
||||||
|
const [history, setHistory] = useState<LabHistoryEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [starting, setStarting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
|
|
||||||
|
async function fetchDecks() {
|
||||||
|
try {
|
||||||
|
const [d, p] = await Promise.all([labApi.getLabDecks(), labApi.getPrecons()])
|
||||||
|
setDecks(d)
|
||||||
|
setPrecons(p)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDecks()
|
||||||
|
labApi.getLabStatus().then(setStatus).catch(() => {})
|
||||||
|
labApi.getLabHistory().then(setHistory).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Poll while running
|
||||||
|
usePolling(async () => {
|
||||||
|
try {
|
||||||
|
const s = await labApi.getLabStatus()
|
||||||
|
setStatus(s)
|
||||||
|
if (!s.running && s.run_id) {
|
||||||
|
const r = await labApi.getLabResult()
|
||||||
|
setResult(r)
|
||||||
|
labApi.getLabHistory().then(setHistory).catch(() => {})
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 2000, status?.running || false)
|
||||||
|
|
||||||
|
function toggleDeck(name: string) {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(name)) next.delete(name)
|
||||||
|
else next.add(name)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStart() {
|
||||||
|
if (selected.size < 2) { setError('Select at least 2 decks'); return }
|
||||||
|
setStarting(true)
|
||||||
|
setError(null)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
await labApi.startBatchSim({ decks: [...selected], games })
|
||||||
|
const s = await labApi.getLabStatus()
|
||||||
|
setStatus(s)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to start')
|
||||||
|
}
|
||||||
|
setStarting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInstallPrecon(name: string) {
|
||||||
|
try {
|
||||||
|
await labApi.installPrecon(name)
|
||||||
|
fetchDecks()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||||
|
<FlaskConical className="w-6 h-6 text-accent-teal" />
|
||||||
|
Batch Simulator
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-0.5">Run mass simulations between decks</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-5">
|
||||||
|
{/* Left sidebar: deck selection + import */}
|
||||||
|
<div className="w-72 flex-shrink-0 space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32"><Spinner size="md" className="text-accent-blue" /></div>
|
||||||
|
) : (
|
||||||
|
<DeckSelector
|
||||||
|
decks={decks}
|
||||||
|
selected={selected}
|
||||||
|
onToggle={toggleDeck}
|
||||||
|
precons={precons}
|
||||||
|
onInstallPrecon={handleInstallPrecon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ImportPanel onImported={fetchDecks} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 space-y-5">
|
||||||
|
{/* Config + Start */}
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={10000}
|
||||||
|
step={10}
|
||||||
|
value={games}
|
||||||
|
onChange={e => setGames(Number(e.target.value))}
|
||||||
|
className="mt-1 w-28 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-text-tertiary">Selected: {selected.size} decks</p>
|
||||||
|
</div>
|
||||||
|
{status?.running ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Spinner size="sm" className="text-accent-blue" />
|
||||||
|
<span className="text-sm text-text-secondary">
|
||||||
|
{status.games_completed} / {status.total_games} games
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-48 h-2 bg-bg-tertiary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent-blue rounded-full transition-all"
|
||||||
|
style={{ width: `${status.total_games ? (status.games_completed / status.total_games) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleStart} disabled={starting || selected.size < 2}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-accent-teal/15 text-accent-teal text-sm font-semibold rounded-lg hover:bg-accent-teal/25 border border-accent-teal/30 transition-colors disabled:opacity-50">
|
||||||
|
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
|
||||||
|
Start Simulation
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="mt-3 flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
|
||||||
|
<AlertCircle className="w-4 h-4" />{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{result && (
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<Trophy className="w-4 h-4 text-accent-amber" />
|
||||||
|
Results
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-text-tertiary">{result.total_games} games · {new Date(result.timestamp).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<ResultsChart result={result} />
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-primary">
|
||||||
|
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium uppercase tracking-wider">Deck</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Wins</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Games</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Win Rate</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Avg Turns</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-primary">
|
||||||
|
{result.decks.sort((a, b) => b.win_rate - a.win_rate).map((d, i) => (
|
||||||
|
<tr key={d.name} className="hover:bg-bg-hover transition-colors">
|
||||||
|
<td className="px-3 py-2.5 flex items-center gap-2">
|
||||||
|
{i === 0 && <Trophy className="w-3.5 h-3.5 text-accent-amber" />}
|
||||||
|
<span className="font-medium text-text-primary">{d.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-text-secondary">{d.wins}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-text-secondary">{d.games}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right font-semibold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-text-secondary">{d.avg_turns.toFixed(1)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setShowHistory(!showHistory)}
|
||||||
|
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors">
|
||||||
|
<History className="w-4 h-4" />
|
||||||
|
Run History ({history.length})
|
||||||
|
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showHistory ? 'rotate-90' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{showHistory && history.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2 animate-fade-in">
|
||||||
|
{history.map(h => (
|
||||||
|
<div key={h.run_id} className="flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary">
|
||||||
|
<Clock className="w-4 h-4 text-text-tertiary" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-text-primary">{h.decks.join(' vs ')}</p>
|
||||||
|
<p className="text-xs text-text-tertiary">{new Date(h.timestamp).toLocaleString()} · {h.total_games} games</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
GraduationCap, MessageSquare, Download,
|
||||||
|
ChevronRight, Sparkles, AlertCircle, X
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Spinner, StatusBadge, EmptyState } from '../components/common'
|
||||||
|
import { coachApi } from '../api'
|
||||||
|
import type { CoachStatus, CoachDeck, CoachSession } from '../types'
|
||||||
|
|
||||||
|
// ── Chat Session View ─────────────────────────────────────────
|
||||||
|
function ChatView({ session, onClose }: { session: CoachSession; onClose: () => void }) {
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [session.messages])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-[calc(100vh-160px)]">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-border-primary">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary">{session.deck_name}</h3>
|
||||||
|
<p className="text-xs text-text-tertiary">{new Date(session.created_at).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1 rounded hover:bg-bg-hover text-text-tertiary">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||||
|
{session.messages.map((msg, i) => (
|
||||||
|
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div className={`max-w-[80%] px-4 py-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-accent-blue/15 text-text-primary border border-accent-blue/20'
|
||||||
|
: 'bg-bg-tertiary text-text-primary border border-border-primary'
|
||||||
|
}`}>
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Coach Page ───────────────────────────────────────────
|
||||||
|
export function CoachPage() {
|
||||||
|
const [status, setStatus] = useState<CoachStatus | null>(null)
|
||||||
|
const [decks, setDecks] = useState<CoachDeck[]>([])
|
||||||
|
const [sessions, setSessions] = useState<CoachSession[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedSession, setSelectedSession] = useState<CoachSession | null>(null)
|
||||||
|
const [generatingReport, setGeneratingReport] = useState<number | null>(null)
|
||||||
|
const [downloadingEmbeddings, setDownloadingEmbeddings] = useState(false)
|
||||||
|
const [showSessions, setShowSessions] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [s, d, sess] = await Promise.all([
|
||||||
|
coachApi.getCoachStatus(),
|
||||||
|
coachApi.getCoachDecks(),
|
||||||
|
coachApi.getCoachSessions(),
|
||||||
|
])
|
||||||
|
setStatus(s)
|
||||||
|
setDecks(d)
|
||||||
|
setSessions(sess)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleGenerateReport(deckId: number) {
|
||||||
|
setGeneratingReport(deckId)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await coachApi.generateReport(deckId)
|
||||||
|
// Refresh decks
|
||||||
|
const d = await coachApi.getCoachDecks()
|
||||||
|
setDecks(d)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Report generation failed')
|
||||||
|
}
|
||||||
|
setGeneratingReport(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadEmbeddings() {
|
||||||
|
setDownloadingEmbeddings(true)
|
||||||
|
try {
|
||||||
|
await coachApi.downloadEmbeddings()
|
||||||
|
const s = await coachApi.getCoachStatus()
|
||||||
|
setStatus(s)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setDownloadingEmbeddings(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewSession(sessionId: string) {
|
||||||
|
try {
|
||||||
|
const s = await coachApi.getCoachSession(sessionId)
|
||||||
|
setSelectedSession(s)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSession) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ChatView session={selectedSession} onClose={() => setSelectedSession(null)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||||
|
<GraduationCap className="w-6 h-6 text-accent-amber" />
|
||||||
|
Deck Coach
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-0.5">AI-powered deck analysis and coaching</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusBadge
|
||||||
|
variant={status?.llm_connected ? 'success' : 'warning'}
|
||||||
|
label={status?.llm_connected ? `LLM: ${status.active_model}` : 'LLM: Disconnected'}
|
||||||
|
/>
|
||||||
|
<StatusBadge
|
||||||
|
variant={status?.embeddings_loaded ? 'success' : 'neutral'}
|
||||||
|
label={status?.embeddings_loaded ? `${status.embedding_cards.toLocaleString()} embeddings` : 'No embeddings'}
|
||||||
|
/>
|
||||||
|
{!status?.embeddings_loaded && (
|
||||||
|
<button onClick={handleDownloadEmbeddings} disabled={downloadingEmbeddings}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-accent-blue hover:text-accent-blue-hover transition-colors">
|
||||||
|
{downloadingEmbeddings ? <Spinner size="sm" /> : <Download className="w-3 h-3" />}
|
||||||
|
Download Embeddings
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
|
||||||
|
<AlertCircle className="w-4 h-4" />{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Decks for coaching */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-text-primary mb-3">Your Decks</h2>
|
||||||
|
{decks.length === 0 ? (
|
||||||
|
<EmptyState icon={GraduationCap} title="No decks available" description="Create decks in the Deck Builder to use the coach." />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{decks.map(deck => (
|
||||||
|
<div key={deck.deck_id} className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-text-primary">{deck.deck_name}</h3>
|
||||||
|
<p className="text-sm text-text-secondary">{deck.commander}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-text-tertiary">
|
||||||
|
<span>{deck.report_count} reports</span>
|
||||||
|
{deck.last_report_date && <span>Last: {new Date(deck.last_report_date).toLocaleDateString()}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleGenerateReport(deck.deck_id)}
|
||||||
|
disabled={generatingReport === deck.deck_id}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-accent-amber/15 text-accent-amber text-xs font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{generatingReport === deck.deck_id ? <Spinner size="sm" /> : <Sparkles className="w-3.5 h-3.5" />}
|
||||||
|
Generate Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sessions */}
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setShowSessions(!showSessions)}
|
||||||
|
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors">
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
Coaching Sessions ({sessions.length})
|
||||||
|
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showSessions ? 'rotate-90' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{showSessions && sessions.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2 animate-fade-in">
|
||||||
|
{sessions.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.session_id}
|
||||||
|
onClick={() => viewSession(s.session_id)}
|
||||||
|
className="w-full flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary hover:border-border-secondary text-left transition-colors"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-4 h-4 text-text-tertiary" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-text-primary">{s.deck_name}</p>
|
||||||
|
<p className="text-xs text-text-tertiary">{new Date(s.created_at).toLocaleString()} · {s.messages.length} messages</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-text-tertiary" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
Search, Filter, Upload, Download, Camera, RefreshCw,
|
||||||
|
ChevronLeft, ChevronRight, X, Package, SlidersHorizontal,
|
||||||
|
Plus, Grid3x3, List
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Spinner, EmptyState, ManaSymbols, CardImage } from '../components/common'
|
||||||
|
import { collectionApi } from '../api'
|
||||||
|
import type { CollectionCard, CollectionFilters, SetInfo } from '../types'
|
||||||
|
|
||||||
|
// ── Card Detail Modal ─────────────────────────────────────────
|
||||||
|
function CardDetailModal({ card, onClose }: { card: CollectionCard; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto animate-fade-in"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between p-5 border-b border-border-primary">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-text-primary">{card.name}</h2>
|
||||||
|
<p className="text-sm text-text-secondary mt-0.5">{card.type_line}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1 rounded hover:bg-bg-hover text-text-tertiary">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col md:flex-row gap-6 p-5">
|
||||||
|
{/* Large card image */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<CardImage src={card.image_url} alt={card.name} size="xl" />
|
||||||
|
</div>
|
||||||
|
{/* Card info */}
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Mana Cost</label>
|
||||||
|
<div className="mt-1"><ManaSymbols cost={card.mana_cost} size="md" /></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Oracle Text</label>
|
||||||
|
<p className="mt-1 text-sm text-text-primary whitespace-pre-wrap leading-relaxed">{card.oracle_text}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
|
||||||
|
<p className="mt-1 text-sm text-text-primary">{card.set_name} ({card.set_code.toUpperCase()})</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
|
||||||
|
<p className={`mt-1 text-sm font-medium capitalize ${
|
||||||
|
card.rarity === 'mythic' ? 'text-accent-amber' :
|
||||||
|
card.rarity === 'rare' ? 'text-accent-amber/80' :
|
||||||
|
card.rarity === 'uncommon' ? 'text-accent-teal' : 'text-text-secondary'
|
||||||
|
}`}>{card.rarity}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC</label>
|
||||||
|
<p className="mt-1 text-sm text-text-primary">{card.cmc}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">EDHREC Rank</label>
|
||||||
|
<p className="mt-1 text-sm text-text-primary">{card.edhrec_rank ?? 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Quantity</label>
|
||||||
|
<p className="mt-1 text-sm text-text-primary">{card.quantity} (+ {card.foil_quantity} foil)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Price</label>
|
||||||
|
<p className="mt-1 text-sm text-accent-teal font-semibold">
|
||||||
|
${card.tcg_price?.toFixed(2) ?? '—'}
|
||||||
|
{card.foil_price ? <span className="text-text-tertiary ml-2">Foil: ${card.foil_price.toFixed(2)}</span> : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scanner Panel ─────────────────────────────────────────────
|
||||||
|
function ScannerPanel({ onCardAdded }: { onCardAdded: () => void }) {
|
||||||
|
const [scanning, setScanning] = useState(false)
|
||||||
|
const [results, setResults] = useState<import('../types').ScanMatch[]>([])
|
||||||
|
const [adding, setAdding] = useState<string | null>(null)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
async function handleScan(file: File) {
|
||||||
|
setScanning(true)
|
||||||
|
setResults([])
|
||||||
|
try {
|
||||||
|
const res = await collectionApi.scanCard(file)
|
||||||
|
setResults(res.matches || [])
|
||||||
|
} catch {
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
setScanning(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd(match: import('../types').ScanMatch) {
|
||||||
|
setAdding(match.scryfall_id)
|
||||||
|
try {
|
||||||
|
await collectionApi.addScanResult({
|
||||||
|
name: match.name,
|
||||||
|
set_code: match.set_code,
|
||||||
|
scryfall_id: match.scryfall_id,
|
||||||
|
})
|
||||||
|
onCardAdded()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setAdding(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2 mb-3">
|
||||||
|
<Camera className="w-4 h-4 text-accent-blue" />
|
||||||
|
Card Scanner
|
||||||
|
</h3>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => e.target.files?.[0] && handleScan(e.target.files[0])}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={scanning}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-accent-blue/10 text-accent-blue border border-accent-blue/30 rounded-lg text-sm font-medium hover:bg-accent-blue/20 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{scanning ? <Spinner size="sm" /> : <Camera className="w-4 h-4" />}
|
||||||
|
{scanning ? 'Scanning...' : 'Scan a Card'}
|
||||||
|
</button>
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2 max-h-60 overflow-y-auto">
|
||||||
|
{results.map(m => (
|
||||||
|
<div key={m.scryfall_id} className="flex items-center gap-3 p-2 rounded-lg bg-bg-tertiary">
|
||||||
|
<CardImage src={m.image_url} alt={m.name} size="sm" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-primary truncate">{m.name}</p>
|
||||||
|
<p className="text-xs text-text-tertiary">{m.set_name} — {(m.confidence * 100).toFixed(0)}%</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAdd(m)}
|
||||||
|
disabled={adding === m.scryfall_id}
|
||||||
|
className="p-1.5 rounded-lg bg-accent-green/15 text-accent-green hover:bg-accent-green/25 transition-colors"
|
||||||
|
>
|
||||||
|
{adding === m.scryfall_id ? <Spinner size="sm" /> : <Plus className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Collection Page ──────────────────────────────────────
|
||||||
|
export function CollectionPage() {
|
||||||
|
const [cards, setCards] = useState<CollectionCard[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [sets, setSets] = useState<SetInfo[]>([])
|
||||||
|
const [selectedCard, setSelectedCard] = useState<CollectionCard | null>(null)
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||||
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [enriching, setEnriching] = useState(false)
|
||||||
|
const importRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<CollectionFilters>({
|
||||||
|
q: '',
|
||||||
|
page: 1,
|
||||||
|
page_size: 48,
|
||||||
|
sort_by: 'name',
|
||||||
|
sort_dir: 'asc',
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchCards = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await collectionApi.searchCollection(filters)
|
||||||
|
setCards(res.cards)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false)
|
||||||
|
}, [filters])
|
||||||
|
|
||||||
|
useEffect(() => { fetchCards() }, [fetchCards])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
collectionApi.getSets().then(setSets).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / (filters.page_size || 48))
|
||||||
|
|
||||||
|
function updateFilter(key: keyof CollectionFilters, value: string | number | boolean | undefined) {
|
||||||
|
setFilters(prev => ({ ...prev, [key]: value, page: 1 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
const blob = await collectionApi.exportCollection('csv')
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'collection.csv'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport(file: File) {
|
||||||
|
setImporting(true)
|
||||||
|
try {
|
||||||
|
await collectionApi.importCollection(file)
|
||||||
|
fetchCards()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReEnrich() {
|
||||||
|
setEnriching(true)
|
||||||
|
try {
|
||||||
|
await collectionApi.reEnrichCards()
|
||||||
|
fetchCards()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setEnriching(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary">Collection</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-0.5">{total.toLocaleString()} cards in your collection</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={handleReEnrich} disabled={enriching}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
|
||||||
|
{enriching ? <Spinner size="sm" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
Re-Enrich
|
||||||
|
</button>
|
||||||
|
<input ref={importRef} type="file" accept=".csv,.txt" className="hidden"
|
||||||
|
onChange={e => e.target.files?.[0] && handleImport(e.target.files[0])} />
|
||||||
|
<button onClick={() => importRef.current?.click()} disabled={importing}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
|
||||||
|
{importing ? <Spinner size="sm" /> : <Upload className="w-3.5 h-3.5" />}
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<button onClick={handleExport}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-5">
|
||||||
|
{/* Left sidebar: search + filters + scanner */}
|
||||||
|
<div className="w-72 flex-shrink-0 space-y-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search cards..."
|
||||||
|
value={filters.q || ''}
|
||||||
|
onChange={e => updateFilter('q', e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-secondary hover:bg-bg-hover transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<SlidersHorizontal className="w-4 h-4" />
|
||||||
|
Filters
|
||||||
|
</span>
|
||||||
|
<Filter className={`w-3.5 h-3.5 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3 animate-fade-in">
|
||||||
|
{/* Rarity */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
|
||||||
|
<select
|
||||||
|
value={filters.rarity || ''}
|
||||||
|
onChange={e => updateFilter('rarity', e.target.value || undefined)}
|
||||||
|
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="common">Common</option>
|
||||||
|
<option value="uncommon">Uncommon</option>
|
||||||
|
<option value="rare">Rare</option>
|
||||||
|
<option value="mythic">Mythic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/* Color */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Color</label>
|
||||||
|
<select
|
||||||
|
value={filters.color || ''}
|
||||||
|
onChange={e => updateFilter('color', e.target.value || undefined)}
|
||||||
|
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="W">White</option>
|
||||||
|
<option value="U">Blue</option>
|
||||||
|
<option value="B">Black</option>
|
||||||
|
<option value="R">Red</option>
|
||||||
|
<option value="G">Green</option>
|
||||||
|
<option value="C">Colorless</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/* Set */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
|
||||||
|
<select
|
||||||
|
value={filters.set_code || ''}
|
||||||
|
onChange={e => updateFilter('set_code', e.target.value || undefined)}
|
||||||
|
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||||
|
>
|
||||||
|
<option value="">All Sets</option>
|
||||||
|
{sets.map(s => (
|
||||||
|
<option key={s.code} value={s.code}>{s.name} ({s.card_count})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/* CMC Range */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Min</label>
|
||||||
|
<input type="number" min="0" max="20"
|
||||||
|
value={filters.cmc_min ?? ''}
|
||||||
|
onChange={e => updateFilter('cmc_min', e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Max</label>
|
||||||
|
<input type="number" min="0" max="20"
|
||||||
|
value={filters.cmc_max ?? ''}
|
||||||
|
onChange={e => updateFilter('cmc_max', e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Sort */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Sort By</label>
|
||||||
|
<select
|
||||||
|
value={filters.sort_by || 'name'}
|
||||||
|
onChange={e => updateFilter('sort_by', e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||||
|
>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
<option value="cmc">CMC</option>
|
||||||
|
<option value="tcg_price">Price</option>
|
||||||
|
<option value="rarity">Rarity</option>
|
||||||
|
<option value="set_code">Set</option>
|
||||||
|
<option value="added_date">Date Added</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scanner */}
|
||||||
|
<ScannerPanel onCardAdded={fetchCards} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content: card grid/list */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* View toggle + pagination */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`p-1.5 rounded-md transition-colors ${viewMode === 'grid' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
|
||||||
|
>
|
||||||
|
<Grid3x3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`p-1.5 rounded-md transition-colors ${viewMode === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||||
|
<span>Page {filters.page} of {totalPages || 1}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters(p => ({ ...p, page: Math.max(1, (p.page || 1) - 1) }))}
|
||||||
|
disabled={!filters.page || filters.page <= 1}
|
||||||
|
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters(p => ({ ...p, page: Math.min(totalPages, (p.page || 1) + 1) }))}
|
||||||
|
disabled={(filters.page || 1) >= totalPages}
|
||||||
|
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner size="lg" className="text-accent-blue" />
|
||||||
|
</div>
|
||||||
|
) : cards.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Package}
|
||||||
|
title="No cards found"
|
||||||
|
description="Try adjusting your search or filters, or import a collection."
|
||||||
|
/>
|
||||||
|
) : viewMode === 'grid' ? (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||||
|
{cards.map(card => (
|
||||||
|
<div
|
||||||
|
key={card.id}
|
||||||
|
className="group cursor-pointer"
|
||||||
|
onClick={() => setSelectedCard(card)}
|
||||||
|
>
|
||||||
|
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
|
||||||
|
<div className="mt-1.5 px-0.5">
|
||||||
|
<p className="text-xs font-medium text-text-primary truncate group-hover:text-accent-blue transition-colors">{card.name}</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-text-tertiary">{card.set_code.toUpperCase()}</span>
|
||||||
|
{card.tcg_price != null && (
|
||||||
|
<span className="text-[10px] text-accent-teal font-medium">${card.tcg_price.toFixed(2)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-primary text-left">
|
||||||
|
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
|
||||||
|
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Type</th>
|
||||||
|
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
|
||||||
|
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Set</th>
|
||||||
|
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Qty</th>
|
||||||
|
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-primary">
|
||||||
|
{cards.map(card => (
|
||||||
|
<tr key={card.id} className="hover:bg-bg-hover cursor-pointer transition-colors" onClick={() => setSelectedCard(card)}>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span className="font-medium text-text-primary">{card.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.type_line}</td>
|
||||||
|
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
|
||||||
|
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.set_code.toUpperCase()}</td>
|
||||||
|
<td className="px-4 py-2.5 text-text-secondary">{card.quantity}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-accent-teal font-medium">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card detail modal */}
|
||||||
|
{selectedCard && <CardDetailModal card={selectedCard} onClose={() => setSelectedCard(null)} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Plus, Trash2, Download, ChevronRight,
|
||||||
|
BarChart3, Lightbulb, Layers, X
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Spinner, EmptyState, ManaSymbols, CardImage, ColorDots } from '../components/common'
|
||||||
|
import { decksApi } from '../api'
|
||||||
|
import type { Deck, DeckCard, DeckAnalysis, DeckRecommendation } from '../types'
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||||
|
|
||||||
|
// ── Deck Card List ────────────────────────────────────────────
|
||||||
|
function DeckCardList({ deckId, cards, onUpdate }: { deckId: number; cards: DeckCard[]; onUpdate: () => void }) {
|
||||||
|
const [removing, setRemoving] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Group cards by category
|
||||||
|
const grouped = cards.reduce<Record<string, DeckCard[]>>((acc, c) => {
|
||||||
|
const cat = c.category || 'Other'
|
||||||
|
if (!acc[cat]) acc[cat] = []
|
||||||
|
acc[cat].push(c)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const categoryOrder = ['Commander', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Other']
|
||||||
|
const sortedCategories = Object.keys(grouped).sort((a, b) => {
|
||||||
|
const ia = categoryOrder.indexOf(a)
|
||||||
|
const ib = categoryOrder.indexOf(b)
|
||||||
|
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleRemove(cardId: number) {
|
||||||
|
setRemoving(cardId)
|
||||||
|
try {
|
||||||
|
await decksApi.removeCardFromDeck(deckId, cardId)
|
||||||
|
onUpdate()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setRemoving(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sortedCategories.map(cat => (
|
||||||
|
<div key={cat}>
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">
|
||||||
|
{cat} ({grouped[cat].length})
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{grouped[cat].map(card => (
|
||||||
|
<div key={card.id} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover group transition-colors">
|
||||||
|
<span className="text-xs text-text-tertiary w-5 text-right">{card.quantity}x</span>
|
||||||
|
<ManaSymbols cost={card.mana_cost} />
|
||||||
|
<span className="flex-1 text-sm text-text-primary truncate">{card.name}</span>
|
||||||
|
{card.tcg_price != null && (
|
||||||
|
<span className="text-xs text-accent-teal">${card.tcg_price.toFixed(2)}</span>
|
||||||
|
)}
|
||||||
|
{!card.is_commander && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(card.id)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 p-1 text-text-tertiary hover:text-accent-red transition-all"
|
||||||
|
>
|
||||||
|
{removing === card.id ? <Spinner size="sm" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mana Curve Chart ──────────────────────────────────────────
|
||||||
|
function ManaCurveChart({ analysis }: { analysis: DeckAnalysis }) {
|
||||||
|
const data = Object.entries(analysis.mana_curve)
|
||||||
|
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
|
||||||
|
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-40">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={data} barSize={24}>
|
||||||
|
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 11 }} />
|
||||||
|
<YAxis hide />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
|
||||||
|
labelStyle={{ color: '#9ba1b8' }}
|
||||||
|
itemStyle={{ color: '#e8eaf0' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||||
|
{data.map((_, i) => (
|
||||||
|
<Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deck Detail View ──────────────────────────────────────────
|
||||||
|
function DeckDetail({ deck, onBack }: { deck: Deck; onBack: () => void }) {
|
||||||
|
const [cards, setCards] = useState<DeckCard[]>([])
|
||||||
|
const [analysis, setAnalysis] = useState<DeckAnalysis | null>(null)
|
||||||
|
const [recs, setRecs] = useState<DeckRecommendation[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [tab, setTab] = useState<'cards' | 'analysis' | 'recs'>('cards')
|
||||||
|
const [addingCard, setAddingCard] = useState('')
|
||||||
|
const [bulkText, setBulkText] = useState('')
|
||||||
|
const [showBulk, setShowBulk] = useState(false)
|
||||||
|
|
||||||
|
async function fetchAll() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [c, a] = await Promise.all([
|
||||||
|
decksApi.getDeckCards(deck.id),
|
||||||
|
decksApi.getDeckAnalysis(deck.id),
|
||||||
|
])
|
||||||
|
setCards(c)
|
||||||
|
setAnalysis(a)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchAll() }, [deck.id])
|
||||||
|
|
||||||
|
async function loadRecs() {
|
||||||
|
try {
|
||||||
|
const r = await decksApi.getCollectionRecs(deck.id)
|
||||||
|
setRecs(r)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { if (tab === 'recs' && recs.length === 0) loadRecs() }, [tab])
|
||||||
|
|
||||||
|
async function handleAddCard() {
|
||||||
|
if (!addingCard.trim()) return
|
||||||
|
try {
|
||||||
|
await decksApi.addCardToDeck(deck.id, { name: addingCard.trim() })
|
||||||
|
setAddingCard('')
|
||||||
|
fetchAll()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBulkAdd() {
|
||||||
|
if (!bulkText.trim()) return
|
||||||
|
const cardLines = bulkText.split('\n').filter(l => l.trim())
|
||||||
|
const cardParsed = cardLines.map(l => {
|
||||||
|
const match = l.match(/^(\d+)x?\s+(.+)$/)
|
||||||
|
return match ? { name: match[2].trim(), quantity: Number(match[1]) } : { name: l.trim() }
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await decksApi.bulkAddCards(deck.id, cardParsed)
|
||||||
|
setBulkText('')
|
||||||
|
setShowBulk(false)
|
||||||
|
fetchAll()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExportToSim() {
|
||||||
|
try {
|
||||||
|
await decksApi.exportDeckToSim(deck.id)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button onClick={onBack} className="p-2 rounded-lg hover:bg-bg-hover text-text-secondary transition-colors">
|
||||||
|
<ChevronRight className="w-5 h-5 rotate-180" />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-bold text-text-primary">{deck.name}</h2>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<span className="text-sm text-text-secondary">{deck.commander}</span>
|
||||||
|
<ColorDots colors={deck.color_identity} />
|
||||||
|
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
|
||||||
|
<span className="text-xs text-accent-teal">${deck.total_price.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleExportToSim}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
Export to Sim
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 border-b border-border-primary">
|
||||||
|
{(['cards', 'analysis', 'recs'] as const).map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors capitalize ${
|
||||||
|
tab === t
|
||||||
|
? 'border-accent-blue text-accent-blue'
|
||||||
|
: 'border-transparent text-text-tertiary hover:text-text-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === 'recs' ? 'Recommendations' : t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||||
|
) : tab === 'cards' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Add card */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add card by name..."
|
||||||
|
value={addingCard}
|
||||||
|
onChange={e => setAddingCard(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleAddCard()}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
/>
|
||||||
|
<button onClick={handleAddCard}
|
||||||
|
className="px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowBulk(!showBulk)}
|
||||||
|
className="px-3 py-2.5 bg-bg-tertiary text-text-secondary text-sm rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showBulk && (
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 animate-fade-in space-y-3">
|
||||||
|
<textarea
|
||||||
|
placeholder={"1x Sol Ring\n1x Command Tower\n2x Counterspell"}
|
||||||
|
value={bulkText}
|
||||||
|
onChange={e => setBulkText(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-4 py-3 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
|
||||||
|
/>
|
||||||
|
<button onClick={handleBulkAdd}
|
||||||
|
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
|
||||||
|
Bulk Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DeckCardList deckId={deck.id} cards={cards} onUpdate={fetchAll} />
|
||||||
|
</div>
|
||||||
|
) : tab === 'analysis' && analysis ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-4 h-4 text-accent-blue" />
|
||||||
|
Deck Stats
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Cards', value: analysis.card_count },
|
||||||
|
{ label: 'Lands', value: analysis.land_count },
|
||||||
|
{ label: 'Creatures', value: analysis.creature_count },
|
||||||
|
{ label: 'Non-Creature', value: analysis.noncreature_count },
|
||||||
|
{ label: 'Avg CMC', value: analysis.avg_cmc.toFixed(2) },
|
||||||
|
{ label: 'Owned', value: `${analysis.owned_count} / ${analysis.card_count}` },
|
||||||
|
].map(stat => (
|
||||||
|
<div key={stat.label} className="px-3 py-2 rounded-lg bg-bg-tertiary">
|
||||||
|
<p className="text-xs text-text-tertiary">{stat.label}</p>
|
||||||
|
<p className="text-lg font-bold text-text-primary">{stat.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Mana Curve */}
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary">Mana Curve</h3>
|
||||||
|
<ManaCurveChart analysis={analysis} />
|
||||||
|
</div>
|
||||||
|
{/* Type Distribution */}
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-3 md:col-span-2">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary">Type Distribution</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(analysis.type_distribution).map(([type, count]) => (
|
||||||
|
<span key={type} className="px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs text-text-secondary">
|
||||||
|
{type}: <span className="font-semibold text-text-primary">{count}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : tab === 'recs' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{recs.length === 0 ? (
|
||||||
|
<EmptyState icon={Lightbulb} title="No recommendations yet" description="Recommendations are generated from your collection." />
|
||||||
|
) : (
|
||||||
|
recs.map(rec => (
|
||||||
|
<div key={rec.scryfall_id} className="flex items-center gap-4 p-3 bg-bg-secondary rounded-xl border border-border-primary hover:border-border-secondary transition-colors">
|
||||||
|
<CardImage src={rec.image_url} alt={rec.name} size="sm" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-primary">{rec.name}</p>
|
||||||
|
<p className="text-xs text-text-secondary truncate">{rec.reason}</p>
|
||||||
|
</div>
|
||||||
|
<ManaSymbols cost={rec.mana_cost} />
|
||||||
|
<span className="text-xs text-accent-teal">{rec.tcg_price != null ? `$${rec.tcg_price.toFixed(2)}` : ''}</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded ${rec.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary'}`}>
|
||||||
|
{rec.owned ? 'Owned' : 'Missing'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Decks Page ───────────────────────────────────────────
|
||||||
|
export function DecksPage() {
|
||||||
|
const [decks, setDecks] = useState<Deck[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedDeck, setSelectedDeck] = useState<Deck | null>(null)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [newDeckName, setNewDeckName] = useState('')
|
||||||
|
const [newCommander, setNewCommander] = useState('')
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
async function fetchDecks() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const d = await decksApi.listDecks()
|
||||||
|
setDecks(d)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchDecks() }, [])
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!newDeckName.trim()) return
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const deck = await decksApi.createDeck({ name: newDeckName.trim(), commander: newCommander.trim() || undefined })
|
||||||
|
setNewDeckName('')
|
||||||
|
setNewCommander('')
|
||||||
|
setShowCreate(false)
|
||||||
|
setSelectedDeck(deck)
|
||||||
|
fetchDecks()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(deckId: number) {
|
||||||
|
try {
|
||||||
|
await decksApi.deleteDeck(deckId)
|
||||||
|
fetchDecks()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDeck) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<DeckDetail deck={selectedDeck} onBack={() => { setSelectedDeck(null); fetchDecks() }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary">Decks</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-0.5">{decks.length} deck{decks.length !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New Deck
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create form */}
|
||||||
|
{showCreate && (
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 animate-fade-in space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary">Create New Deck</h3>
|
||||||
|
<button onClick={() => setShowCreate(false)} className="text-text-tertiary hover:text-text-secondary">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Deck name"
|
||||||
|
value={newDeckName}
|
||||||
|
onChange={e => setNewDeckName(e.target.value)}
|
||||||
|
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Commander (optional)"
|
||||||
|
value={newCommander}
|
||||||
|
onChange={e => setNewCommander(e.target.value)}
|
||||||
|
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleCreate} disabled={creating}
|
||||||
|
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
|
||||||
|
{creating ? <Spinner size="sm" /> : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deck list */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||||
|
) : decks.length === 0 ? (
|
||||||
|
<EmptyState icon={Layers} title="No decks yet" description="Create your first deck to get started." />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{decks.map(deck => (
|
||||||
|
<div
|
||||||
|
key={deck.id}
|
||||||
|
className="bg-bg-secondary rounded-xl border border-border-primary hover:border-border-secondary p-5 cursor-pointer transition-all group"
|
||||||
|
onClick={() => setSelectedDeck(deck)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="text-base font-semibold text-text-primary truncate group-hover:text-accent-blue transition-colors">{deck.name}</h3>
|
||||||
|
<p className="text-sm text-text-secondary mt-0.5 truncate">{deck.commander}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); handleDelete(deck.id) }}
|
||||||
|
className="opacity-0 group-hover:opacity-100 p-1.5 text-text-tertiary hover:text-accent-red transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ColorDots colors={deck.color_identity} />
|
||||||
|
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-accent-teal">${deck.total_price.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Gamepad2, Play, Wifi, WifiOff, Settings,
|
||||||
|
AlertCircle, Trophy, Clock, Brain
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Spinner, StatusBadge } from '../components/common'
|
||||||
|
import { simApi, labApi } from '../api'
|
||||||
|
import { usePolling } from '../hooks/usePolling'
|
||||||
|
import type { SimStatus, SimResult, DeepSeekStatus, LabDeck } from '../types'
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||||
|
|
||||||
|
export function SimulatorPage() {
|
||||||
|
const [decks, setDecks] = useState<LabDeck[]>([])
|
||||||
|
const [deckA, setDeckA] = useState('')
|
||||||
|
const [deckB, setDeckB] = useState('')
|
||||||
|
const [games, setGames] = useState(50)
|
||||||
|
const [useDeepSeek, setUseDeepSeek] = useState(false)
|
||||||
|
const [deepSeekDeck, setDeepSeekDeck] = useState('')
|
||||||
|
const [dsStatus, setDsStatus] = useState<DeepSeekStatus | null>(null)
|
||||||
|
const [running, setRunning] = useState(false)
|
||||||
|
const [simId, setSimId] = useState<string | null>(null)
|
||||||
|
const [simStatus, setSimStatus] = useState<SimStatus | null>(null)
|
||||||
|
const [result, setResult] = useState<SimResult | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [starting, setStarting] = useState(false)
|
||||||
|
|
||||||
|
// DS config
|
||||||
|
const [showDsConfig, setShowDsConfig] = useState(false)
|
||||||
|
const [dsModel, setDsModel] = useState('')
|
||||||
|
const [dsUrl, setDsUrl] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
labApi.getLabDecks().then(setDecks).catch(() => {})
|
||||||
|
simApi.getDeepSeekStatus().then(s => {
|
||||||
|
setDsStatus(s)
|
||||||
|
if (s.model) setDsModel(s.model)
|
||||||
|
if (s.base_url) setDsUrl(s.base_url)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Poll while running
|
||||||
|
usePolling(async () => {
|
||||||
|
if (!simId) return
|
||||||
|
try {
|
||||||
|
const s = await simApi.getSimStatus(simId)
|
||||||
|
setSimStatus(s)
|
||||||
|
if (!s.running) {
|
||||||
|
const r = await simApi.getSimResult(simId)
|
||||||
|
setResult(r)
|
||||||
|
setRunning(false)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 2000, running)
|
||||||
|
|
||||||
|
async function handleStart() {
|
||||||
|
if (!deckA || !deckB) { setError('Select both decks'); return }
|
||||||
|
setStarting(true)
|
||||||
|
setError(null)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
let res
|
||||||
|
if (useDeepSeek && deepSeekDeck) {
|
||||||
|
res = await simApi.runDeepSeekSim({ deck_a: deckA, deck_b: deckB, games, deepseek_deck: deepSeekDeck })
|
||||||
|
} else {
|
||||||
|
res = await simApi.runSim({ deck_a: deckA, deck_b: deckB, games })
|
||||||
|
}
|
||||||
|
setSimId(res.sim_id)
|
||||||
|
setRunning(true)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to start')
|
||||||
|
}
|
||||||
|
setStarting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDsConnect() {
|
||||||
|
try {
|
||||||
|
const s = await simApi.connectDeepSeek()
|
||||||
|
setDsStatus(s)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDsConfigure() {
|
||||||
|
try {
|
||||||
|
const s = await simApi.configureDeepSeek({
|
||||||
|
model: dsModel || undefined,
|
||||||
|
base_url: dsUrl || undefined,
|
||||||
|
})
|
||||||
|
setDsStatus(s)
|
||||||
|
setShowDsConfig(false)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||||
|
<Gamepad2 className="w-6 h-6 text-accent-purple" />
|
||||||
|
Simulator
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-0.5">Run head-to-head deck simulations</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DeepSeek status */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusBadge
|
||||||
|
variant={dsStatus?.connected ? 'success' : 'warning'}
|
||||||
|
label={dsStatus?.connected ? `DeepSeek: ${dsStatus.model}` : 'DeepSeek: Disconnected'}
|
||||||
|
/>
|
||||||
|
<button onClick={handleDsConnect}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
|
||||||
|
{dsStatus?.connected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
||||||
|
{dsStatus?.connected ? 'Reconnect' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowDsConfig(!showDsConfig)}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
|
||||||
|
<Settings className="w-3 h-3" />
|
||||||
|
Configure
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDsConfig && (
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 max-w-md animate-fade-in space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4 text-accent-purple" />
|
||||||
|
DeepSeek Configuration
|
||||||
|
</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Model name"
|
||||||
|
value={dsModel}
|
||||||
|
onChange={e => setDsModel(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Base URL (e.g. http://192.168.0.122:1234)"
|
||||||
|
value={dsUrl}
|
||||||
|
onChange={e => setDsUrl(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
/>
|
||||||
|
<button onClick={handleDsConfigure}
|
||||||
|
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sim config */}
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck A</label>
|
||||||
|
<select
|
||||||
|
value={deckA}
|
||||||
|
onChange={e => setDeckA(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
>
|
||||||
|
<option value="">Select deck...</option>
|
||||||
|
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck B</label>
|
||||||
|
<select
|
||||||
|
value={deckB}
|
||||||
|
onChange={e => setDeckB(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
>
|
||||||
|
<option value="">Select deck...</option>
|
||||||
|
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={1000}
|
||||||
|
value={games}
|
||||||
|
onChange={e => setGames(Number(e.target.value))}
|
||||||
|
className="mt-1 w-24 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer mt-5">
|
||||||
|
<input type="checkbox" checked={useDeepSeek} onChange={e => setUseDeepSeek(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-purple focus:ring-accent-purple/30" />
|
||||||
|
<span className="text-sm text-text-secondary">Use DeepSeek AI pilot</span>
|
||||||
|
</label>
|
||||||
|
{useDeepSeek && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<select
|
||||||
|
value={deepSeekDeck}
|
||||||
|
onChange={e => setDeepSeekDeck(e.target.value)}
|
||||||
|
className="px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||||
|
>
|
||||||
|
<option value="">AI pilots which deck?</option>
|
||||||
|
{[deckA, deckB].filter(Boolean).map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
|
||||||
|
<AlertCircle className="w-4 h-4" />{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{running ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Spinner size="md" className="text-accent-blue" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-text-primary">Simulation running...</p>
|
||||||
|
{simStatus && (
|
||||||
|
<p className="text-xs text-text-tertiary">
|
||||||
|
{simStatus.games_completed ?? 0} / {simStatus.total_games ?? games} games
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleStart} disabled={starting || !deckA || !deckB}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-semibold rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-50">
|
||||||
|
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
|
||||||
|
Run Simulation
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{result && (
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<Trophy className="w-4 h-4 text-accent-amber" />
|
||||||
|
Results
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-text-tertiary flex items-center gap-1.5">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{(result.duration_ms / 1000).toFixed(1)}s · {result.total_games} games
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-48">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={result.decks.map(d => ({ name: d.name, win_rate: Math.round(d.win_rate * 100) }))} barSize={40}>
|
||||||
|
<XAxis dataKey="name" tick={{ fill: '#9ba1b8', fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
|
||||||
|
formatter={(value: unknown) => [`${value}%`, 'Win Rate']} />
|
||||||
|
<Bar dataKey="win_rate" radius={[4, 4, 0, 0]}>
|
||||||
|
{result.decks.map((d, i) => (
|
||||||
|
<Cell key={i} fill={d.win_rate === Math.max(...result.decks.map(x => x.win_rate)) ? '#4ade80' : '#4f6ef7'} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{result.decks.map(d => (
|
||||||
|
<div key={d.name} className="px-4 py-3 rounded-lg bg-bg-tertiary">
|
||||||
|
<p className="text-sm font-semibold text-text-primary">{d.name}</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-text-tertiary uppercase">Wins</p>
|
||||||
|
<p className="text-lg font-bold text-accent-green">{d.wins}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-text-tertiary uppercase">Win Rate</p>
|
||||||
|
<p className="text-lg font-bold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-text-tertiary uppercase">Avg Turn</p>
|
||||||
|
<p className="text-lg font-bold text-text-primary">{d.avg_turn.toFixed(1)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Brain, Play, RotateCw, Database, Trophy, AlertCircle,
|
||||||
|
ToggleLeft, ToggleRight, Activity, Zap, Swords
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Spinner, StatusBadge } from '../components/common'
|
||||||
|
import { trainingApi } from '../api'
|
||||||
|
import { usePolling } from '../hooks/usePolling'
|
||||||
|
import type {
|
||||||
|
MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus,
|
||||||
|
TournamentStatus, TournamentResults
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
// ── Progress Bar ──────────────────────────────────────────────
|
||||||
|
function ProgressBar({ value, max, label }: { value: number; max: number; label: string }) {
|
||||||
|
const pct = max > 0 ? (value / max) * 100 : 0
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span className="text-text-secondary">{label}</span>
|
||||||
|
<span className="text-text-tertiary">{value} / {max}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-bg-tertiary rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-accent-blue rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Training Page ────────────────────────────────────────
|
||||||
|
export function TrainingPage() {
|
||||||
|
const [mlStatus, setMlStatus] = useState<MLStatus | null>(null)
|
||||||
|
const [dataStatus, setDataStatus] = useState<MLDataStatus | null>(null)
|
||||||
|
const [trainStatus, setTrainStatus] = useState<MLTrainStatus | null>(null)
|
||||||
|
const [ppoStatus, setPpoStatus] = useState<PPOTrainStatus | null>(null)
|
||||||
|
const [tournStatus, setTournStatus] = useState<TournamentStatus | null>(null)
|
||||||
|
const [tournResults, setTournResults] = useState<TournamentResults | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Training config
|
||||||
|
const [epochs, setEpochs] = useState(100)
|
||||||
|
const [lr, setLr] = useState(0.001)
|
||||||
|
const [ppoIterations, setPpoIterations] = useState(50)
|
||||||
|
const [tournGames, setTournGames] = useState(100)
|
||||||
|
|
||||||
|
// Action states
|
||||||
|
const [startingTrain, setStartingTrain] = useState(false)
|
||||||
|
const [startingPpo, setStartingPpo] = useState(false)
|
||||||
|
const [startingTourn, setStartingTourn] = useState(false)
|
||||||
|
const [toggling, setToggling] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [ml, data] = await Promise.all([
|
||||||
|
trainingApi.getMLStatus(),
|
||||||
|
trainingApi.getDataStatus(),
|
||||||
|
])
|
||||||
|
setMlStatus(ml)
|
||||||
|
setDataStatus(data)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Poll training status
|
||||||
|
const isTraining = trainStatus?.running || false
|
||||||
|
const isPpo = ppoStatus?.running || false
|
||||||
|
const isTourn = tournStatus?.running || false
|
||||||
|
|
||||||
|
usePolling(async () => {
|
||||||
|
try {
|
||||||
|
const s = await trainingApi.getTrainStatus()
|
||||||
|
setTrainStatus(s)
|
||||||
|
if (!s.running) {
|
||||||
|
const ml = await trainingApi.getMLStatus()
|
||||||
|
setMlStatus(ml)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 2000, isTraining)
|
||||||
|
|
||||||
|
usePolling(async () => {
|
||||||
|
try {
|
||||||
|
const s = await trainingApi.getPPOStatus()
|
||||||
|
setPpoStatus(s)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 2000, isPpo)
|
||||||
|
|
||||||
|
usePolling(async () => {
|
||||||
|
try {
|
||||||
|
const s = await trainingApi.getTournamentStatus()
|
||||||
|
setTournStatus(s)
|
||||||
|
if (!s.running) {
|
||||||
|
const r = await trainingApi.getTournamentResults()
|
||||||
|
setTournResults(r)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 2000, isTourn)
|
||||||
|
|
||||||
|
async function handleToggle() {
|
||||||
|
setToggling(true)
|
||||||
|
try {
|
||||||
|
await trainingApi.toggleML()
|
||||||
|
const ml = await trainingApi.getMLStatus()
|
||||||
|
setMlStatus(ml)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setToggling(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStartTrain() {
|
||||||
|
setStartingTrain(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await trainingApi.startTraining({ epochs, lr })
|
||||||
|
const s = await trainingApi.getTrainStatus()
|
||||||
|
setTrainStatus(s)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to start training')
|
||||||
|
}
|
||||||
|
setStartingTrain(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStartPpo() {
|
||||||
|
setStartingPpo(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await trainingApi.startPPOTraining({ iterations: ppoIterations })
|
||||||
|
const s = await trainingApi.getPPOStatus()
|
||||||
|
setPpoStatus(s)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to start PPO training')
|
||||||
|
}
|
||||||
|
setStartingPpo(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStartTournament() {
|
||||||
|
setStartingTourn(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await trainingApi.startTournament({ games: tournGames })
|
||||||
|
const s = await trainingApi.getTournamentStatus()
|
||||||
|
setTournStatus(s)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to start tournament')
|
||||||
|
}
|
||||||
|
setStartingTourn(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReloadModel() {
|
||||||
|
try {
|
||||||
|
await trainingApi.reloadModel()
|
||||||
|
const ml = await trainingApi.getMLStatus()
|
||||||
|
setMlStatus(ml)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||||
|
<Brain className="w-6 h-6 text-accent-green" />
|
||||||
|
ML Training
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-0.5">Train and evaluate the decision-making model</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
|
||||||
|
<AlertCircle className="w-4 h-4" />{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status bar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<StatusBadge
|
||||||
|
variant={mlStatus?.enabled ? 'success' : 'neutral'}
|
||||||
|
label={mlStatus?.enabled ? 'ML Enabled' : 'ML Disabled'}
|
||||||
|
/>
|
||||||
|
<StatusBadge
|
||||||
|
variant={mlStatus?.model_loaded ? 'success' : 'warning'}
|
||||||
|
label={mlStatus?.model_loaded ? 'Model Loaded' : 'No Model'}
|
||||||
|
/>
|
||||||
|
<StatusBadge
|
||||||
|
variant={dataStatus?.dataset_exists ? 'info' : 'neutral'}
|
||||||
|
label={dataStatus?.dataset_exists ? `${dataStatus.samples} samples` : 'No Dataset'}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-text-tertiary">{mlStatus?.decisions_count ?? 0} decision files</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
|
{/* ML Toggle + Model */}
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<Activity className="w-4 h-4 text-accent-blue" />
|
||||||
|
Model Status
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-text-secondary">ML Engine</span>
|
||||||
|
<button onClick={handleToggle} disabled={toggling}
|
||||||
|
className="flex items-center gap-2 text-sm transition-colors">
|
||||||
|
{toggling ? <Spinner size="sm" /> : mlStatus?.enabled
|
||||||
|
? <ToggleRight className="w-6 h-6 text-accent-green" />
|
||||||
|
: <ToggleLeft className="w-6 h-6 text-text-tertiary" />
|
||||||
|
}
|
||||||
|
<span className={mlStatus?.enabled ? 'text-accent-green' : 'text-text-tertiary'}>
|
||||||
|
{mlStatus?.enabled ? 'On' : 'Off'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{mlStatus?.model_path && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-text-tertiary">Model: {mlStatus.model_path}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button onClick={handleReloadModel}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||||
|
<RotateCw className="w-3.5 h-3.5" />
|
||||||
|
Reload Model
|
||||||
|
</button>
|
||||||
|
{dataStatus && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Dataset</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="px-3 py-2 bg-bg-tertiary rounded-lg">
|
||||||
|
<p className="text-[10px] text-text-tertiary">Samples</p>
|
||||||
|
<p className="text-sm font-bold text-text-primary">{dataStatus.samples.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 bg-bg-tertiary rounded-lg">
|
||||||
|
<p className="text-[10px] text-text-tertiary">Features</p>
|
||||||
|
<p className="text-sm font-bold text-text-primary">{dataStatus.features}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{dataStatus.last_built && (
|
||||||
|
<p className="text-[10px] text-text-tertiary">Built: {new Date(dataStatus.last_built).toLocaleString()}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supervised Training */}
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<Database className="w-4 h-4 text-accent-blue" />
|
||||||
|
Supervised Training
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary">Epochs</label>
|
||||||
|
<input type="number" min={1} max={1000} value={epochs} onChange={e => setEpochs(Number(e.target.value))}
|
||||||
|
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary">Learning Rate</label>
|
||||||
|
<input type="number" min={0.00001} max={1} step={0.0001} value={lr} onChange={e => setLr(Number(e.target.value))}
|
||||||
|
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{trainStatus?.running ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ProgressBar value={trainStatus.epoch} max={trainStatus.total_epochs} label="Training progress" />
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="px-3 py-2 bg-bg-tertiary rounded-lg">
|
||||||
|
<p className="text-[10px] text-text-tertiary">Epoch</p>
|
||||||
|
<p className="text-sm font-bold text-text-primary">{trainStatus.epoch}/{trainStatus.total_epochs}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 bg-bg-tertiary rounded-lg">
|
||||||
|
<p className="text-[10px] text-text-tertiary">Loss</p>
|
||||||
|
<p className="text-sm font-bold text-accent-amber">{trainStatus.loss?.toFixed(4) ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 bg-bg-tertiary rounded-lg">
|
||||||
|
<p className="text-[10px] text-text-tertiary">Accuracy</p>
|
||||||
|
<p className="text-sm font-bold text-accent-green">{trainStatus.accuracy != null ? `${(trainStatus.accuracy * 100).toFixed(1)}%` : '—'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleStartTrain} disabled={startingTrain}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue/15 text-accent-blue text-sm font-medium rounded-lg hover:bg-accent-blue/25 border border-accent-blue/30 transition-colors disabled:opacity-50">
|
||||||
|
{startingTrain ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
|
||||||
|
Start Training
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{trainStatus && !trainStatus.running && trainStatus.best_accuracy != null && (
|
||||||
|
<p className="text-xs text-text-secondary">Best accuracy: <span className="text-accent-green font-medium">{(trainStatus.best_accuracy * 100).toFixed(1)}%</span></p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PPO Training */}
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<Zap className="w-4 h-4 text-accent-purple" />
|
||||||
|
PPO Reinforcement Learning
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary">Iterations</label>
|
||||||
|
<input type="number" min={1} max={500} value={ppoIterations} onChange={e => setPpoIterations(Number(e.target.value))}
|
||||||
|
className="mt-1 w-32 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all" />
|
||||||
|
</div>
|
||||||
|
{ppoStatus?.running ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ProgressBar value={ppoStatus.iteration} max={ppoStatus.total_iterations} label="PPO training progress" />
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="px-3 py-2 bg-bg-tertiary rounded-lg">
|
||||||
|
<p className="text-[10px] text-text-tertiary">Avg Reward</p>
|
||||||
|
<p className="text-sm font-bold text-accent-amber">{ppoStatus.avg_reward?.toFixed(3) ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 bg-bg-tertiary rounded-lg">
|
||||||
|
<p className="text-[10px] text-text-tertiary">Win Rate</p>
|
||||||
|
<p className="text-sm font-bold text-accent-green">{ppoStatus.win_rate != null ? `${(ppoStatus.win_rate * 100).toFixed(1)}%` : '—'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleStartPpo} disabled={startingPpo}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-medium rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-50">
|
||||||
|
{startingPpo ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
|
||||||
|
Start PPO Training
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tournament */}
|
||||||
|
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<Swords className="w-4 h-4 text-accent-amber" />
|
||||||
|
Tournament Evaluation
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-text-tertiary">Games per matchup</label>
|
||||||
|
<input type="number" min={10} max={1000} value={tournGames} onChange={e => setTournGames(Number(e.target.value))}
|
||||||
|
className="mt-1 w-32 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all" />
|
||||||
|
</div>
|
||||||
|
{tournStatus?.running ? (
|
||||||
|
<ProgressBar value={tournStatus.games_completed} max={tournStatus.total_games} label="Tournament progress" />
|
||||||
|
) : (
|
||||||
|
<button onClick={handleStartTournament} disabled={startingTourn}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-accent-amber/15 text-accent-amber text-sm font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-50">
|
||||||
|
{startingTourn ? <Spinner size="sm" /> : <Trophy className="w-4 h-4" />}
|
||||||
|
Start Tournament
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{tournResults && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-primary">
|
||||||
|
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium">Player</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Wins</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Games</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Win Rate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-primary">
|
||||||
|
{tournResults.players.sort((a, b) => b.win_rate - a.win_rate).map((p, i) => (
|
||||||
|
<tr key={p.name} className="hover:bg-bg-hover transition-colors">
|
||||||
|
<td className="px-3 py-2 flex items-center gap-2">
|
||||||
|
{i === 0 && <Trophy className="w-3 h-3 text-accent-amber" />}
|
||||||
|
<span className="text-text-primary">{p.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-text-secondary">{p.wins}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-text-secondary">{p.games}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-semibold text-accent-blue">{(p.win_rate * 100).toFixed(1)}%</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Commander AI Lab — TypeScript Type Definitions
|
||||||
|
// Derived from the FastAPI backend's Pydantic models + SQLite schema
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ── Cards & Collection ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CollectionCard {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
scryfall_id: string
|
||||||
|
set_code: string
|
||||||
|
set_name: string
|
||||||
|
collector_number: string
|
||||||
|
rarity: string
|
||||||
|
type_line: string
|
||||||
|
mana_cost: string
|
||||||
|
cmc: number
|
||||||
|
oracle_text: string
|
||||||
|
color_identity: string[]
|
||||||
|
quantity: number
|
||||||
|
foil_quantity: number
|
||||||
|
condition: string
|
||||||
|
tcg_price: number | null
|
||||||
|
foil_price: number | null
|
||||||
|
image_url: string
|
||||||
|
edhrec_rank: number | null
|
||||||
|
keywords: string
|
||||||
|
added_date: string
|
||||||
|
updated_date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionFilters {
|
||||||
|
q?: string
|
||||||
|
type_line?: string
|
||||||
|
color?: string
|
||||||
|
rarity?: string
|
||||||
|
set_code?: string
|
||||||
|
cmc_min?: number
|
||||||
|
cmc_max?: number
|
||||||
|
price_min?: number
|
||||||
|
price_max?: number
|
||||||
|
owned_only?: boolean
|
||||||
|
sort_by?: string
|
||||||
|
sort_dir?: 'asc' | 'desc'
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionStats {
|
||||||
|
total_cards: number
|
||||||
|
total_unique: number
|
||||||
|
total_value: number
|
||||||
|
sets_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetInfo {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
card_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scan ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
matches: ScanMatch[]
|
||||||
|
image_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanMatch {
|
||||||
|
name: string
|
||||||
|
set_code: string
|
||||||
|
set_name: string
|
||||||
|
collector_number: string
|
||||||
|
scryfall_id: string
|
||||||
|
confidence: number
|
||||||
|
image_url: string
|
||||||
|
type_line: string
|
||||||
|
oracle_text: string
|
||||||
|
mana_cost: string
|
||||||
|
tcg_price: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Deck {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
commander: string
|
||||||
|
commander_scryfall_id?: string
|
||||||
|
commander_image_url?: string
|
||||||
|
color_identity: string[]
|
||||||
|
format: string
|
||||||
|
card_count: number
|
||||||
|
total_price: number
|
||||||
|
created_date: string
|
||||||
|
updated_date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckCard {
|
||||||
|
id: number
|
||||||
|
deck_id: number
|
||||||
|
name: string
|
||||||
|
scryfall_id: string
|
||||||
|
quantity: number
|
||||||
|
is_commander: boolean
|
||||||
|
category: string
|
||||||
|
type_line: string
|
||||||
|
mana_cost: string
|
||||||
|
cmc: number
|
||||||
|
oracle_text: string
|
||||||
|
tcg_price: number | null
|
||||||
|
image_url: string
|
||||||
|
owned_qty: number
|
||||||
|
set_code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckAnalysis {
|
||||||
|
card_count: number
|
||||||
|
land_count: number
|
||||||
|
creature_count: number
|
||||||
|
noncreature_count: number
|
||||||
|
avg_cmc: number
|
||||||
|
mana_curve: Record<number, number>
|
||||||
|
color_distribution: Record<string, number>
|
||||||
|
type_distribution: Record<string, number>
|
||||||
|
total_price: number
|
||||||
|
owned_count: number
|
||||||
|
missing_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckRecommendation {
|
||||||
|
name: string
|
||||||
|
scryfall_id: string
|
||||||
|
type_line: string
|
||||||
|
mana_cost: string
|
||||||
|
oracle_text: string
|
||||||
|
image_url: string
|
||||||
|
tcg_price: number | null
|
||||||
|
reason: string
|
||||||
|
score: number
|
||||||
|
owned: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── V3 Deck Generator ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DeckGenV3Request {
|
||||||
|
commander_name: string
|
||||||
|
strategy?: string
|
||||||
|
target_bracket: number
|
||||||
|
budget_usd?: number
|
||||||
|
budget_mode?: 'total' | 'per_card'
|
||||||
|
omit_cards?: string[]
|
||||||
|
use_collection?: boolean
|
||||||
|
run_substitution?: boolean
|
||||||
|
model?: string
|
||||||
|
deck_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckGenV3Status {
|
||||||
|
initialized: boolean
|
||||||
|
pplx_configured: boolean
|
||||||
|
model: string | null
|
||||||
|
embeddings_loaded: boolean
|
||||||
|
embedding_cards: number
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckGenV3Card {
|
||||||
|
name: string
|
||||||
|
quantity: number
|
||||||
|
category: string
|
||||||
|
type_line: string
|
||||||
|
mana_cost: string
|
||||||
|
cmc: number
|
||||||
|
role: string
|
||||||
|
reason: string
|
||||||
|
tcg_price: number | null
|
||||||
|
image_url: string
|
||||||
|
owned: boolean
|
||||||
|
owned_qty: number
|
||||||
|
scryfall_id: string
|
||||||
|
is_substitute: boolean
|
||||||
|
original_name: string | null
|
||||||
|
substitution_reason: string | null
|
||||||
|
similarity_score: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckGenV3Result {
|
||||||
|
commander: {
|
||||||
|
name: string
|
||||||
|
image_url: string
|
||||||
|
type_line: string
|
||||||
|
color_identity: string[]
|
||||||
|
mana_cost: string
|
||||||
|
oracle_text: string
|
||||||
|
}
|
||||||
|
cards: DeckGenV3Card[]
|
||||||
|
stats: DeckAnalysis
|
||||||
|
strategy: string
|
||||||
|
bracket: number
|
||||||
|
model: string
|
||||||
|
substitution?: {
|
||||||
|
owned: number
|
||||||
|
substituted: number
|
||||||
|
missing: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Simulation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SimConfig {
|
||||||
|
deck_names: string[]
|
||||||
|
num_games: number
|
||||||
|
threads: number
|
||||||
|
use_deepseek?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimStatus {
|
||||||
|
running: boolean
|
||||||
|
sim_id: string
|
||||||
|
progress?: number
|
||||||
|
games_completed?: number
|
||||||
|
total_games?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimResult {
|
||||||
|
sim_id: string
|
||||||
|
decks: SimDeckResult[]
|
||||||
|
total_games: number
|
||||||
|
duration_ms: number
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimDeckResult {
|
||||||
|
name: string
|
||||||
|
wins: number
|
||||||
|
losses: number
|
||||||
|
win_rate: number
|
||||||
|
avg_turn: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch Sim (Lab) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LabDeck {
|
||||||
|
name: string
|
||||||
|
source: string
|
||||||
|
path: string
|
||||||
|
card_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabStartRequest {
|
||||||
|
decks: string[]
|
||||||
|
games: number
|
||||||
|
threads?: number
|
||||||
|
use_deepseek?: boolean
|
||||||
|
deepseek_deck?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabStatus {
|
||||||
|
running: boolean
|
||||||
|
run_id: string
|
||||||
|
games_completed: number
|
||||||
|
total_games: number
|
||||||
|
current_decks: string[]
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabResult {
|
||||||
|
run_id: string
|
||||||
|
decks: LabDeckResult[]
|
||||||
|
total_games: number
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabDeckResult {
|
||||||
|
name: string
|
||||||
|
wins: number
|
||||||
|
games: number
|
||||||
|
win_rate: number
|
||||||
|
avg_turns: number
|
||||||
|
avg_life_remaining: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabHistoryEntry {
|
||||||
|
run_id: string
|
||||||
|
timestamp: string
|
||||||
|
decks: string[]
|
||||||
|
total_games: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreconDeck {
|
||||||
|
name: string
|
||||||
|
filename: string
|
||||||
|
release_date?: string
|
||||||
|
set_name?: string
|
||||||
|
installed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Coach ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CoachStatus {
|
||||||
|
llm_connected: boolean
|
||||||
|
active_model: string | null
|
||||||
|
embeddings_loaded: boolean
|
||||||
|
embedding_cards: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoachDeck {
|
||||||
|
deck_id: number
|
||||||
|
deck_name: string
|
||||||
|
commander: string
|
||||||
|
report_count: number
|
||||||
|
last_report_date: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoachSession {
|
||||||
|
session_id: string
|
||||||
|
deck_name: string
|
||||||
|
created_at: string
|
||||||
|
messages: CoachMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoachMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ML Training ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MLStatus {
|
||||||
|
enabled: boolean
|
||||||
|
model_loaded: boolean
|
||||||
|
model_path: string | null
|
||||||
|
decisions_count: number
|
||||||
|
decision_files: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MLDataStatus {
|
||||||
|
dataset_exists: boolean
|
||||||
|
samples: number
|
||||||
|
features: number
|
||||||
|
last_built: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MLTrainStatus {
|
||||||
|
running: boolean
|
||||||
|
epoch: number
|
||||||
|
total_epochs: number
|
||||||
|
loss: number | null
|
||||||
|
accuracy: number | null
|
||||||
|
best_accuracy: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PPOTrainStatus {
|
||||||
|
running: boolean
|
||||||
|
iteration: number
|
||||||
|
total_iterations: number
|
||||||
|
avg_reward: number | null
|
||||||
|
win_rate: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentStatus {
|
||||||
|
running: boolean
|
||||||
|
games_completed: number
|
||||||
|
total_games: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentResults {
|
||||||
|
players: TournamentPlayer[]
|
||||||
|
total_games: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentPlayer {
|
||||||
|
name: string
|
||||||
|
wins: number
|
||||||
|
games: number
|
||||||
|
win_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DeepSeek ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DeepSeekStatus {
|
||||||
|
connected: boolean
|
||||||
|
model: string | null
|
||||||
|
base_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commander Search ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CommanderSearchResult {
|
||||||
|
name: string
|
||||||
|
type_line: string
|
||||||
|
color_identity: string[]
|
||||||
|
mana_cost: string
|
||||||
|
image_url: string
|
||||||
|
oracle_text: string
|
||||||
|
scryfall_id: string
|
||||||
|
in_collection: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Perplexity ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PplxStatus {
|
||||||
|
configured: boolean
|
||||||
|
key_prefix: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: false,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
recharts: ['recharts'],
|
||||||
|
react: ['react', 'react-dom', 'react-router-dom'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
File diff suppressed because it is too large.
Load diff
+1922
-22
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -61,8 +61,12 @@ def build_dataset(
|
|||||||
if not card_index.load():
|
if not card_index.load():
|
||||||
logger.error("Cannot build dataset without card embeddings.")
|
logger.error("Cannot build dataset without card embeddings.")
|
||||||
logger.error("Start the lab server first to auto-download embeddings,")
|
logger.error("Start the lab server first to auto-download embeddings,")
|
||||||
logger.error("or manually download mtg_embeddings.npz to embeddings/")
|
logger.error("or manually download mtg_embeddings.npz to embeddings/ or data/")
|
||||||
return {}
|
raise RuntimeError(
|
||||||
|
"Card embeddings not found. The coach must download them first — "
|
||||||
|
"open the Coach tab and ensure embeddings are loaded, or place "
|
||||||
|
"mtg-embeddings.npz in the data/ folder."
|
||||||
|
)
|
||||||
|
|
||||||
encoder = StateEncoder(card_index)
|
encoder = StateEncoder(card_index)
|
||||||
|
|
||||||
@@ -72,7 +76,10 @@ def build_dataset(
|
|||||||
if not jsonl_files:
|
if not jsonl_files:
|
||||||
logger.error("No ML decision files found in %s", results_dir)
|
logger.error("No ML decision files found in %s", results_dir)
|
||||||
logger.error("Run batch simulations with --ml-log or enable ML logging in the UI first.")
|
logger.error("Run batch simulations with --ml-log or enable ML logging in the UI first.")
|
||||||
return {}
|
raise RuntimeError(
|
||||||
|
f"No ml-decisions-*.jsonl files found in {results_dir}. "
|
||||||
|
"Run batch simulations with ML logging enabled first."
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("Found %d ML decision files", len(jsonl_files))
|
logger.info("Found %d ML decision files", len(jsonl_files))
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+72
-12
@@ -54,33 +54,93 @@ class CardEmbeddingIndex:
|
|||||||
self._zero_vec = np.zeros(STATE_DIMS.card_embedding_dim, dtype=np.float32)
|
self._zero_vec = np.zeros(STATE_DIMS.card_embedding_dim, dtype=np.float32)
|
||||||
|
|
||||||
def load(self) -> bool:
|
def load(self) -> bool:
|
||||||
"""Load embeddings from NPZ cache (created by coach/embeddings.py)."""
|
"""Load embeddings from NPZ cache.
|
||||||
npz_path = os.path.join(self.embeddings_dir, "mtg_embeddings.npz")
|
|
||||||
if not os.path.exists(npz_path):
|
Searches multiple known locations and handles two NPZ formats:
|
||||||
logger.warning(
|
- Coach format: keys 'vectors', 'names' (saved by coach/embeddings.py)
|
||||||
"Embeddings NPZ not found at %s. "
|
- ML format: keys 'embeddings', 'names' (legacy)
|
||||||
"Run the lab server first to auto-download embeddings.",
|
|
||||||
npz_path,
|
If no NPZ is found anywhere, auto-downloads from HuggingFace via
|
||||||
)
|
the coach embedding index and converts to NPZ.
|
||||||
return False
|
"""
|
||||||
|
project_root = Path(__file__).parent.parent.parent
|
||||||
|
candidates = [
|
||||||
|
str(project_root / "data" / "mtg-embeddings.npz"),
|
||||||
|
str(project_root / "data" / "mtg_embeddings.npz"),
|
||||||
|
os.path.join(self.embeddings_dir, "mtg_embeddings.npz"),
|
||||||
|
os.path.join(self.embeddings_dir, "mtg-embeddings.npz"),
|
||||||
|
str(project_root / "embeddings" / "mtg_embeddings.npz"),
|
||||||
|
str(project_root / "embeddings" / "mtg-embeddings.npz"),
|
||||||
|
]
|
||||||
|
npz_path = None
|
||||||
|
for candidate in candidates:
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
npz_path = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
# Auto-download if nothing found
|
||||||
|
if not npz_path:
|
||||||
|
logger.info("Embeddings NPZ not found — attempting auto-download...")
|
||||||
|
npz_path = self._auto_download(project_root)
|
||||||
|
if not npz_path:
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = np.load(npz_path, allow_pickle=True)
|
data = np.load(npz_path, allow_pickle=True)
|
||||||
self._embeddings = data["embeddings"].astype(np.float32)
|
# Handle both NPZ formats:
|
||||||
|
# Coach format has 'vectors', ML legacy has 'embeddings'
|
||||||
|
if "vectors" in data:
|
||||||
|
self._embeddings = data["vectors"].astype(np.float32)
|
||||||
|
elif "embeddings" in data:
|
||||||
|
self._embeddings = data["embeddings"].astype(np.float32)
|
||||||
|
else:
|
||||||
|
logger.error("NPZ at %s has no 'vectors' or 'embeddings' key. Keys: %s",
|
||||||
|
npz_path, list(data.keys()))
|
||||||
|
return False
|
||||||
self._names = list(data["names"])
|
self._names = list(data["names"])
|
||||||
self._name_to_idx = {
|
self._name_to_idx = {
|
||||||
name.lower(): i for i, name in enumerate(self._names)
|
name.lower(): i for i, name in enumerate(self._names)
|
||||||
}
|
}
|
||||||
self._loaded = True
|
self._loaded = True
|
||||||
logger.info(
|
logger.info(
|
||||||
"Loaded %d card embeddings (%d-dim)",
|
"Loaded %d card embeddings (%d-dim) from %s",
|
||||||
len(self._names), self._embeddings.shape[1],
|
len(self._names), self._embeddings.shape[1], npz_path,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to load embeddings: %s", e)
|
logger.error("Failed to load embeddings: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _auto_download(project_root: Path) -> Optional[str]:
|
||||||
|
"""Download embeddings from HuggingFace via the coach module.
|
||||||
|
|
||||||
|
Uses coach/embeddings.py MTGEmbeddingIndex which handles downloading
|
||||||
|
the parquet from HuggingFace and converting to NPZ. Returns the NPZ
|
||||||
|
path on success, or None on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import sys
|
||||||
|
pr = str(project_root)
|
||||||
|
if pr not in sys.path:
|
||||||
|
sys.path.insert(0, pr)
|
||||||
|
from coach.embeddings import MTGEmbeddingIndex
|
||||||
|
from coach.config import EMBEDDINGS_NPZ
|
||||||
|
|
||||||
|
idx = MTGEmbeddingIndex()
|
||||||
|
idx.download_parquet()
|
||||||
|
idx.convert_parquet_to_npz()
|
||||||
|
|
||||||
|
npz_str = str(EMBEDDINGS_NPZ)
|
||||||
|
if os.path.exists(npz_str):
|
||||||
|
logger.info("Auto-downloaded embeddings to %s", npz_str)
|
||||||
|
return npz_str
|
||||||
|
logger.error("Auto-download completed but NPZ not found at %s", npz_str)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Auto-download failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
def get_embedding(self, card_name: str) -> np.ndarray:
|
def get_embedding(self, card_name: str) -> np.ndarray:
|
||||||
"""Get embedding vector for a card name. Returns zero vector if not found."""
|
"""Get embedding vector for a card name. Returns zero vector if not found."""
|
||||||
if not self._loaded:
|
if not self._loaded:
|
||||||
|
|||||||
Binary file not shown.
Loaded 100 of 376 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user