From 74595ddb051483e48f871db5b7a50d37e84a50d3 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Mon, 25 May 2026 03:12:32 -0700 Subject: [PATCH 01/22] chore: refresh metagame feeds (#1048) --- .../public/feeds/mtggoldfish-commander.json | 1814 ++++++++--------- client/public/feeds/mtggoldfish-modern.json | 292 +-- client/public/feeds/mtggoldfish-pioneer.json | 379 ++-- client/public/feeds/mtggoldfish-standard.json | 303 ++- 4 files changed, 1297 insertions(+), 1491 deletions(-) diff --git a/client/public/feeds/mtggoldfish-commander.json b/client/public/feeds/mtggoldfish-commander.json index 171243831a..c769029aaa 100644 --- a/client/public/feeds/mtggoldfish-commander.json +++ b/client/public/feeds/mtggoldfish-commander.json @@ -5,16 +5,14 @@ "icon": "G", "format": "commander", "version": 1, - "updated": "2026-06-08T00:00:00Z", + "updated": "2026-06-09T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/commander", "decks": [ { - "name": "Y'shtola, Night's Blessed", + "name": "Krenko, Mob Boss", "author": "MTGGoldfish", "colors": [ - "W", - "B", - "U" + "R" ], "tags": [ "metagame" @@ -22,576 +20,574 @@ "main": [ { "count": 1, - "name": "Absorb" + "name": "Abrade" }, { "count": 1, - "name": "Alisaie Leveilleur" + "name": "Arms Dealer" }, { "count": 1, - "name": "Alphinaud Leveilleur" + "name": "Boggart Brute" }, { "count": 1, - "name": "Anguished Unmaking" + "name": "Castle Embereth" }, { "count": 1, - "name": "Arcane Sanctum" + "name": "Cathartic Reunion" }, { "count": 1, - "name": "Arcane Signet" + "name": "Cavalcade of Calamity" }, { "count": 1, - "name": "Archaeomancer's Map" + "name": "Comet Storm" }, { "count": 1, - "name": "Archmage Emeritus" + "name": "Dark-Dweller Oracle" }, { "count": 1, - "name": "Ardbert, Warrior of Darkness" + "name": "Destructive Tampering" }, { "count": 1, - "name": "Baleful Strix" + "name": "Dragon Fodder" }, { "count": 1, - "name": "Bender's Waterskin" + "name": "Experimental Frenzy" }, { "count": 1, - "name": "Bloodchief Ascension" + "name": "Fanatical Firebrand" }, { "count": 1, - "name": "Circle of Power" + "name": "Ferocity of the Wilds" }, { "count": 1, - "name": "Command Tower" + "name": "Forgotten Cave" }, { "count": 1, - "name": "Contaminated Aquifer" + "name": "Foundry Street Denizen" }, { "count": 1, - "name": "Crushing Disappointment" + "name": "Frenzied Goblin" }, { "count": 1, - "name": "Curiosity" + "name": "Gempalm Incinerator" }, { "count": 1, - "name": "Deadly Rollick" + "name": "Goblin Arsonist" }, { "count": 1, - "name": "Decanter of Endless Water" + "name": "Goblin Assault" }, { "count": 1, - "name": "Decorum Dissertation" + "name": "Goblin Burrows" }, { "count": 1, - "name": "Defacing Duskmage" + "name": "Goblin Cratermaker" }, { "count": 1, - "name": "Deserted Beach" + "name": "Goblin Dark-Dwellers" }, { "count": 1, - "name": "Disallow" + "name": "Goblin Diplomats" }, { "count": 1, - "name": "Dismember" + "name": "Goblin Fireleaper" }, { "count": 1, - "name": "Drowned Catacomb" + "name": "Goblin Goon" }, { "count": 1, - "name": "Emet-Selch of the Third Seat" + "name": "Goblin Instigator" }, { "count": 1, - "name": "Enduring Tenacity" + "name": "Goblin Matron" }, { "count": 1, - "name": "Exsanguinate" + "name": "Goblin Morningstar" }, { "count": 1, - "name": "Eye of Nidhogg" + "name": "Goblin Motivator" }, { "count": 1, - "name": "Fell the Profane" + "name": "Goblin Oriflamme" }, { "count": 1, - "name": "G'raha Tia, Scion Reborn" + "name": "Goblin Picker" }, { "count": 1, - "name": "Generous Gift" + "name": "Goblin Rally" }, { "count": 1, - "name": "Ghostly Prison" + "name": "Goblin Razerunners" }, { "count": 1, - "name": "Glacial Fortress" + "name": "Goblin Ringleader" }, { "count": 1, - "name": "Godless Shrine" + "name": "Goblin War Party" }, { "count": 1, - "name": "Hallowed Fountain" + "name": "Goblin Wardriver" }, { "count": 1, - "name": "Helm of the Ghastlord" + "name": "Hidden Volcano" }, { "count": 1, - "name": "Hermes, Overseer of Elpis" + "name": "Hordeling Outburst" }, { "count": 1, - "name": "Hullbreaker Horror" + "name": "Impact Tremors" }, { "count": 1, - "name": "Idyllic Beachfront" - }, - { - "count": 4, - "name": "Island" + "name": "Impulsive Pilferer" }, { "count": 1, - "name": "Isolated Chapel" + "name": "Koth, Fire of Resistance" }, { "count": 1, - "name": "Kambal, Consul of Allocation" + "name": "Krenko's Command" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Krenko's Enforcer" }, { "count": 1, - "name": "Lotho, Corrupt Shirriff" + "name": "Krenko, Mob Boss" }, { "count": 1, - "name": "Lyse Hext" + "name": "Krenko, Tin Street Kingpin" }, { "count": 1, - "name": "Mai, Scornful Striker" + "name": "Legion Warboss" }, { "count": 1, - "name": "Mindcrank" + "name": "Lightning Strike" }, { "count": 1, - "name": "Morphic Pool" + "name": "Massive Raid" }, { "count": 1, - "name": "Norn's Annex" + "name": "Mizzium Mortars" }, { "count": 1, - "name": "Ophidian Eye" + "name": "Mogg War Marshal" }, { - "count": 1, - "name": "Overkill" + "count": 33, + "name": "Mountain" }, { "count": 1, - "name": "Papalymo Totolymo" + "name": "Outnumber" }, { "count": 1, - "name": "Phyresis" + "name": "Outpost Siege" }, { - "count": 4, - "name": "Plains" + "count": 1, + "name": "Path of Ancestry" }, { "count": 1, - "name": "Prairie Stream" + "name": "Raid Bombardment" }, { "count": 1, - "name": "Propaganda" + "name": "Rummaging Goblin" }, { "count": 1, - "name": "Queza, Augur of Agonies" + "name": "Runaway Steam-Kin" }, { "count": 1, - "name": "Raffine's Tower" + "name": "Shock" }, { "count": 1, - "name": "Read the Bones" + "name": "Siege-Gang Commander" }, { "count": 1, - "name": "Reliquary Tower" + "name": "Skirk Prospector" }, { "count": 1, - "name": "Rewind" + "name": "Sol Ring" }, { "count": 1, - "name": "Risky Shortcut" + "name": "Squee, Goblin Nabob" }, { "count": 1, - "name": "Roaming Throne" + "name": "Squee, the Immortal" }, { "count": 1, - "name": "Sanguine Bond" + "name": "Tarfire" }, { "count": 1, - "name": "Scheming Silvertongue" + "name": "Thrill of Possibility" }, { "count": 1, - "name": "Sea of Clouds" + "name": "Torch Courier" }, { "count": 1, - "name": "Shattered Sanctum" + "name": "Volley Veteran" }, { "count": 1, - "name": "Sheoldred, the Apocalypse" - }, + "name": "You See a Pair of Goblins" + } + ], + "sideboard": [] + }, + { + "name": "Y'shtola, Night's Blessed", + "author": "MTGGoldfish", + "colors": [ + "W", + "B", + "U" + ], + "tags": [ + "metagame" + ], + "main": [ { "count": 1, - "name": "Shipwreck Marsh" + "name": "Foolish Fate" }, { "count": 1, - "name": "Snuff Out" + "name": "Alisaie Leveilleur" }, { "count": 1, - "name": "Sol Ring" + "name": "Alphinaud Leveilleur" }, { "count": 1, - "name": "Stormscape Familiar" + "name": "Anguished Unmaking" }, { "count": 1, - "name": "Stroke of Midnight" + "name": "Arcane Sanctum" }, { "count": 1, - "name": "Sunken Hollow" + "name": "Arcane Signet" }, { "count": 1, - "name": "Sunlit Marsh" + "name": "Archaeomancer's Map" }, { - "count": 4, - "name": "Swamp" + "count": 1, + "name": "Archmage Emeritus" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Ardbert, Warrior of Darkness" }, { "count": 1, - "name": "Swords to Plowshares" + "name": "Baleful Strix" }, { "count": 1, - "name": "Sygg, River Cutthroat" + "name": "Bender's Waterskin" }, { "count": 1, - "name": "Thought Vessel" + "name": "Bloodchief Ascension" }, { "count": 1, - "name": "Undercity Sewers" + "name": "Circle of Power" }, { "count": 1, - "name": "Undermine" + "name": "Command Tower" }, { "count": 1, - "name": "Unwind" + "name": "Contaminated Aquifer" }, { "count": 1, - "name": "Vault of Champions" + "name": "Crushing Disappointment" }, { "count": 1, - "name": "Vindicate" + "name": "Curiosity" }, { "count": 1, - "name": "Vito, Thorn of the Dusk Rose" + "name": "Deadly Rollick" }, { "count": 1, - "name": "Void Rend" + "name": "Decanter of Endless Water" }, { "count": 1, - "name": "Watery Grave" + "name": "Decorum Dissertation" }, { "count": 1, - "name": "Y'shtola, Night's Blessed" + "name": "Defacing Duskmage" }, { "count": 1, - "name": "Aetherflux Reservoir" - } - ], - "sideboard": [] - }, - { - "name": "Krenko, Mob Boss", - "author": "MTGGoldfish", - "colors": [ - "R" - ], - "tags": [ - "metagame" - ], - "main": [ + "name": "Deserted Beach" + }, { "count": 1, - "name": "Goblin Chirurgeon" + "name": "Disallow" }, { "count": 1, - "name": "Skirk Prospector" + "name": "Dismember" }, { "count": 1, - "name": "Battle Cry Goblin" + "name": "Drowned Catacomb" }, { "count": 1, - "name": "Conspicuous Snoop" + "name": "Emet-Selch of the Third Seat" }, { "count": 1, - "name": "Goblin Instigator" + "name": "Enduring Tenacity" }, { "count": 1, - "name": "Goblin Lookout" + "name": "Exsanguinate" }, { "count": 1, - "name": "Goblin Piledriver" + "name": "Eye of Nidhogg" }, { "count": 1, - "name": "Goblin Recruiter" + "name": "Fell the Profane" }, { "count": 1, - "name": "Hexing Squelcher" + "name": "G'raha Tia, Scion Reborn" }, { "count": 1, - "name": "Rundvelt Hordemaster" + "name": "Generous Gift" }, { "count": 1, - "name": "Combat Celebrant" + "name": "Ghostly Prison" }, { "count": 1, - "name": "General Kreat, the Boltbringer" + "name": "Glacial Fortress" }, { "count": 1, - "name": "Goblin Chieftain" + "name": "Godless Shrine" }, { "count": 1, - "name": "Goblin King" + "name": "Hallowed Fountain" }, { "count": 1, - "name": "Goblin Matron" + "name": "Helm of the Ghastlord" }, { "count": 1, - "name": "Goblin Warchief" + "name": "Hermes, Overseer of Elpis" }, { "count": 1, - "name": "Hobgoblin Bandit Lord" + "name": "Hullbreaker Horror" }, { "count": 1, - "name": "Howlsquad Heavy" + "name": "Idyllic Beachfront" }, { - "count": 1, - "name": "Krenko, Tin Street Kingpin" + "count": 4, + "name": "Island" }, { "count": 1, - "name": "Legion Warboss" + "name": "Isolated Chapel" }, { "count": 1, - "name": "Pashalik Mons" + "name": "Kambal, Consul of Allocation" }, { "count": 1, - "name": "Squee, the Immortal" + "name": "Lightning Greaves" }, { "count": 1, - "name": "Treasure Nabber" + "name": "Lotho, Corrupt Shirriff" }, { "count": 1, - "name": "Anger" + "name": "Lyse Hext" }, { "count": 1, - "name": "Beetleback Chief" + "name": "Mai, Scornful Striker" }, { "count": 1, - "name": "Goblin Ringleader" + "name": "Mindcrank" }, { "count": 1, - "name": "Goblin Trashmaster" + "name": "Morphic Pool" }, { "count": 1, - "name": "Krenko, Mob Boss" + "name": "Norn's Annex" }, { "count": 1, - "name": "Battle Squadron" + "name": "Ophidian Eye" }, { "count": 1, - "name": "Brash Taunter" + "name": "Overkill" }, { "count": 1, - "name": "Kiki-Jiki, Mirror Breaker" + "name": "Papalymo Totolymo" }, { "count": 1, - "name": "Siege-Gang Commander" + "name": "Phyresis" }, { - "count": 1, - "name": "Muxus, Goblin Grandee" + "count": 4, + "name": "Plains" }, { "count": 1, - "name": "Brightstone Ritual" + "name": "Prairie Stream" }, { "count": 1, - "name": "Last-Ditch Effort" + "name": "Propaganda" }, { "count": 1, - "name": "Red Elemental Blast" + "name": "Queza, Augur of Agonies" }, { "count": 1, - "name": "Chaos Warp" + "name": "Raffine's Tower" }, { "count": 1, - "name": "Flare of Duplication" + "name": "Read the Bones" }, { "count": 1, - "name": "Big Score" + "name": "Reliquary Tower" }, { "count": 1, - "name": "Faithless Looting" + "name": "Rewind" }, { "count": 1, - "name": "Goblin War Strike" + "name": "Risky Shortcut" }, { "count": 1, - "name": "Vandalblast" + "name": "Roaming Throne" }, { "count": 1, - "name": "Dragon Fodder" + "name": "Sanguine Bond" }, { "count": 1, - "name": "Krenko's Command" + "name": "Scheming Silvertongue" }, { "count": 1, - "name": "Hordeling Outburst" + "name": "Sea of Clouds" }, { "count": 1, - "name": "Empty the Warrens" + "name": "Shattered Sanctum" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Sheoldred, the Apocalypse" }, { "count": 1, - "name": "Dawn-Blessed Pennant" + "name": "Shipwreck Marsh" }, { "count": 1, - "name": "Skullclamp" + "name": "Snuff Out" }, { "count": 1, @@ -599,80 +595,88 @@ }, { "count": 1, - "name": "Blazing Sunsteel" + "name": "Stormscape Familiar" }, { "count": 1, - "name": "Fellwar Stone" + "name": "Stroke of Midnight" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Sunken Hollow" }, { "count": 1, - "name": "Ruby Medallion" + "name": "Sunlit Marsh" }, { - "count": 1, - "name": "Sting, the Glinting Dagger" + "count": 4, + "name": "Swamp" }, { "count": 1, - "name": "Hazoret's Monument" + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "The Fire Crystal" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Coat of Arms" + "name": "Sygg, River Cutthroat" }, { "count": 1, - "name": "Vanquisher's Banner" + "name": "Thought Vessel" }, { "count": 1, - "name": "Goblin Bombardment" + "name": "Undercity Sewers" }, { "count": 1, - "name": "Impact Tremors" + "name": "Undermine" }, { "count": 1, - "name": "Aggravated Assault" + "name": "Unwind" }, { "count": 1, - "name": "Blood Moon" + "name": "Vault of Champions" }, { "count": 1, - "name": "Fervor" + "name": "Vindicate" }, { "count": 1, - "name": "Goblin War Drums" + "name": "Vito, Thorn of the Dusk Rose" }, { "count": 1, - "name": "Shared Animosity" + "name": "Void Rend" }, { - "count": 34, - "name": "Mountain" + "count": 1, + "name": "Watery Grave" + }, + { + "count": 1, + "name": "Y'shtola, Night's Blessed" + }, + { + "count": 1, + "name": "Aetherflux Reservoir" } ], "sideboard": [] }, { - "name": "Quandrix, the Proof", + "name": "Witherbloom, the Balancer", "author": "MTGGoldfish", "colors": [ - "U", + "B", "G" ], "tags": [ @@ -681,219 +685,227 @@ "main": [ { "count": 1, - "name": "Quandrix Charm" + "name": "Twitching Doll" }, { "count": 1, - "name": "Koma, World-Eater" + "name": "Chatterfang, Squirrel General" }, { "count": 1, - "name": "Ancient Imperiosaur" + "name": "Arbor Elf" }, { "count": 1, - "name": "Planar Engineering" + "name": "Elvish Mystic" }, { "count": 1, - "name": "Mystic Confluence" + "name": "Llanowar Elves" }, { "count": 1, - "name": "Hullbreaker Horror" + "name": "Viscera Seer" }, { "count": 1, - "name": "Imoti, Celebrant of Bounty" + "name": "Blood Artist" }, { "count": 1, - "name": "Rapid Hybridization" + "name": "Bogwater Lumaret" }, { "count": 1, - "name": "Ivy, Gleeful Spellthief" + "name": "Witherbloom Apprentice" }, { "count": 1, - "name": "Talrand, Sky Summoner" + "name": "Zulaport Cutthroat" }, { "count": 1, - "name": "Counterspell" + "name": "Enduring Vitality" }, { "count": 1, - "name": "Skyshroud Claim" + "name": "Sedgemoor Witch" }, { "count": 1, - "name": "Brainsurge" + "name": "Arasta of the Endless Web" }, { "count": 1, - "name": "Quandrix Pledgemage" + "name": "Creakwood Liege" }, { "count": 1, - "name": "Shark Typhoon" + "name": "Hazel of the Rootbloom" }, { "count": 1, - "name": "Proctor's Gaze" + "name": "Pitiless Plunderer" }, { "count": 1, - "name": "Wisdom of Ages" + "name": "Mycoloth" }, { "count": 1, - "name": "Cultivate" + "name": "Tendershoot Dryad" }, { "count": 1, - "name": "Quandrix Command" + "name": "Professor Onyx" }, { "count": 1, - "name": "Archmage of Runes" + "name": "Animist's Awakening" }, { "count": 1, - "name": "Growth Spiral" + "name": "Scatter the Seeds" }, { "count": 1, - "name": "Auroral Procession" + "name": "Scour from Existence" }, { "count": 1, - "name": "Hydro-Channeler" + "name": "For the Common Good" }, { "count": 1, - "name": "Roil Elemental" + "name": "Preposterous Proportions" }, { "count": 1, - "name": "Negate" + "name": "Pest Infestation" }, { "count": 1, - "name": "Eureka Moment" + "name": "Necrotic Hex" }, { "count": 1, - "name": "Double Major" + "name": "Praetor's Counsel" }, { "count": 1, - "name": "Beast Within" + "name": "Chain of Smog" + }, + { + "count": 1, + "name": "Brightcap Badger" }, { "count": 1, - "name": "Aetherize" + "name": "Damnable Pact" }, { "count": 1, - "name": "Rampant Growth" + "name": "Deadly Dispute" }, { "count": 1, - "name": "Into the Story" + "name": "Exsanguinate" }, { "count": 1, - "name": "Planar Genesis" + "name": "Farseek" }, { "count": 1, - "name": "Forceful Denial" + "name": "Nature's Lore" }, { "count": 1, - "name": "Deekah, Fractal Theorist" + "name": "Ophiomancer" }, { "count": 1, - "name": "Fact or Fiction" + "name": "Rampant Growth" }, { "count": 1, - "name": "Apex Devastator" + "name": "Sprout Swarm" }, { "count": 1, - "name": "Turnabout" + "name": "Tear Asunder" }, { "count": 1, - "name": "Llanowar Elves" + "name": "Rise of the Dark Realms" }, { "count": 1, - "name": "Inevitable Betrayal" + "name": "Witherbloom Charm" }, { "count": 1, - "name": "Rashmi, Eternities Crafter" + "name": "Chord of Calling" }, { "count": 1, - "name": "Echocasting Symposium" + "name": "Cultivate" }, { "count": 1, - "name": "Jadzi, Oracle of Arcavios" + "name": "Moseo, Vein's New Dean" }, { "count": 1, - "name": "Praetor's Counsel" + "name": "Ribtruss Roaster" }, { "count": 1, - "name": "Murmuring Mystic" + "name": "Kodama's Reach" }, { "count": 1, - "name": "Time Stretch" + "name": "Pest Rescuer" }, { "count": 1, - "name": "Haughty Djinn" + "name": "Army of the Damned" }, { "count": 1, - "name": "Body of Research" + "name": "Saproling Symbiosis" }, { "count": 1, - "name": "Lier, Disciple of the Drowned" + "name": "Dark Petition" }, { "count": 1, - "name": "Traverse the Outlands" + "name": "Dregs of Sorrow" }, { "count": 1, - "name": "Hermes, Overseer of Elpis" + "name": "Increasing Ambition" }, { "count": 1, - "name": "Omniscience" + "name": "Shamanic Revelation" }, { "count": 1, - "name": "High Fae Trickster" + "name": "Windgrace's Judgment" }, { "count": 1, - "name": "Arcane Signet" + "name": "Beseech the Queen" + }, + { + "count": 1, + "name": "Collective Unconscious" }, { "count": 1, - "name": "Arixmethes, Slumbering Isle" + "name": "Eternal Witness" }, { "count": 1, @@ -901,65 +913,93 @@ }, { "count": 1, - "name": "Archaeomancer" + "name": "Golgari Signet" }, { "count": 1, - "name": "Talisman of Curiosity" + "name": "Talisman of Resilience" }, { "count": 1, - "name": "Dirgur Focusmage" + "name": "Tooth and Nail" }, { "count": 1, - "name": "Chrome Host Seedshark" + "name": "Insidious Roots" }, { "count": 1, - "name": "Berta, Wise Extrapolator" + "name": "Springleaf Parade" }, { "count": 1, - "name": "Kianne, Corrupted Memory" + "name": "Phyrexian Arena" }, { "count": 1, - "name": "Quandrix, the Proof" + "name": "Unbound Flourishing" }, { - "count": 17, - "name": "Island" + "count": 1, + "name": "Command Tower" }, { - "count": 17, - "name": "Forest" + "count": 1, + "name": "Darkbore Pathway" }, { "count": 1, - "name": "Command Tower" + "name": "Llanowar Wastes" }, { "count": 1, - "name": "Sodden Verdure" + "name": "Overgrown Tomb" }, { "count": 1, - "name": "Breeding Pool" + "name": "Reliquary Tower" + }, + { + "count": 1, + "name": "Tainted Wood" + }, + { + "count": 1, + "name": "Temple of Malady" + }, + { + "count": 1, + "name": "Twilight Mire" + }, + { + "count": 1, + "name": "Vernal Fen" + }, + { + "count": 1, + "name": "Viridescent Bog" + }, + { + "count": 12, + "name": "Forest" + }, + { + "count": 13, + "name": "Swamp" }, { "count": 1, - "name": "Dreamroot Cascade" + "name": "Witherbloom, the Balancer" } ], "sideboard": [] }, { - "name": "Witherbloom, the Balancer", + "name": "Quandrix, the Proof", "author": "MTGGoldfish", "colors": [ - "B", - "G" + "G", + "U" ], "tags": [ "metagame" @@ -967,321 +1007,309 @@ "main": [ { "count": 1, - "name": "Twitching Doll" + "name": "Quandrix, the Proof" }, { "count": 1, - "name": "Chatterfang, Squirrel General" + "name": "Flooded Grove" }, { "count": 1, - "name": "Arbor Elf" + "name": "Yavimaya Coast" }, { - "count": 1, - "name": "Elvish Mystic" + "count": 11, + "name": "Forest" }, { - "count": 1, - "name": "Llanowar Elves" + "count": 17, + "name": "Island" }, { "count": 1, - "name": "Viscera Seer" + "name": "Tangled Islet" }, { "count": 1, - "name": "Blood Artist" + "name": "Vineglimmer Snarl" }, { "count": 1, - "name": "Bogwater Lumaret" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Witherbloom Apprentice" + "name": "Mystic Sanctuary" }, { "count": 1, - "name": "Zulaport Cutthroat" + "name": "Temple of the False God" }, { "count": 1, - "name": "Enduring Vitality" + "name": "Inevitable Betrayal" }, { "count": 1, - "name": "Sedgemoor Witch" + "name": "Ancestral Vision" }, { "count": 1, - "name": "Arasta of the Endless Web" + "name": "Sol Ring" }, { "count": 1, - "name": "Creakwood Liege" + "name": "Llanowar Elves" }, { "count": 1, - "name": "Hazel of the Rootbloom" + "name": "Preordain" }, { "count": 1, - "name": "Pitiless Plunderer" + "name": "Brainstorm" }, { "count": 1, - "name": "Mycoloth" + "name": "Unable to Scream" }, { "count": 1, - "name": "Tendershoot Dryad" + "name": "Serum Visions" }, { "count": 1, - "name": "Professor Onyx" + "name": "Fellwar Stone" }, { "count": 1, - "name": "Animist's Awakening" + "name": "Baral, Chief of Compliance" }, { "count": 1, - "name": "Dark Ritual" + "name": "Boomerang" }, { "count": 1, - "name": "Scour from Existence" + "name": "Farseek" }, { "count": 1, - "name": "For the Common Good" + "name": "Rampant Growth" }, { "count": 1, - "name": "Preposterous Proportions" + "name": "Reality Shift" }, { "count": 1, - "name": "Pest Infestation" + "name": "Growth Spiral" }, { "count": 1, - "name": "Necrotic Hex" + "name": "Explore" }, { "count": 1, - "name": "Praetor's Counsel" + "name": "Get Out" }, { "count": 1, - "name": "Chain of Smog" + "name": "Naturalize" }, { "count": 1, - "name": "Brightcap Badger" + "name": "Sapphire Medallion" }, { "count": 1, - "name": "Damnable Pact" + "name": "Resculpt" }, { "count": 1, - "name": "Deadly Dispute" + "name": "Return to Nature" }, { "count": 1, - "name": "Exsanguinate" + "name": "Nature's Lore" }, { "count": 1, - "name": "Farseek" + "name": "Shared Roots" }, { "count": 1, - "name": "Nature's Lore" + "name": "Arcane Signet" }, { "count": 1, - "name": "Ophiomancer" + "name": "Simic Signet" }, { "count": 1, - "name": "Rampant Growth" + "name": "Tribute to the Wild" }, { "count": 1, - "name": "Sprout Swarm" + "name": "Amphibian Downpour" }, { "count": 1, - "name": "Tear Asunder" + "name": "Archmage's Charm" }, { "count": 1, - "name": "Rise of the Dark Realms" + "name": "Cultivate" }, { "count": 1, - "name": "Witherbloom Charm" + "name": "Cycle of Renewal" }, { "count": 1, - "name": "Chord of Calling" + "name": "Brainsurge" }, { "count": 1, - "name": "Cultivate" + "name": "Harrow" }, { "count": 1, - "name": "Moseo, Vein's New Dean" + "name": "Beast Within" }, { "count": 1, - "name": "Genesis Wave" + "name": "Haughty Djinn" }, { "count": 1, - "name": "Kodama's Reach" + "name": "Entish Restoration" }, { "count": 1, - "name": "Culling Ritual" + "name": "Stock Up" }, { "count": 1, - "name": "Empty the Pits" + "name": "Archmage Emeritus" }, { "count": 1, - "name": "Saproling Symbiosis" + "name": "Leyline of Anticipation" }, { "count": 1, - "name": "Dark Petition" + "name": "Map the Frontier" }, { "count": 1, - "name": "Dregs of Sorrow" + "name": "Consuming Tide" }, { "count": 1, - "name": "Increasing Ambition" + "name": "Eureka Moment" }, { "count": 1, - "name": "Shamanic Revelation" + "name": "Naru Meha, Master Wizard" }, { "count": 1, - "name": "Windgrace's Judgment" + "name": "Wash Out" }, { "count": 1, - "name": "Beseech the Queen" + "name": "The Water Crystal" }, { "count": 1, - "name": "Collective Unconscious" + "name": "Venser, Shaper Savant" }, { "count": 1, - "name": "Eternal Witness" + "name": "Peregrine Drake" }, { "count": 1, - "name": "Sol Ring" + "name": "Embrace the Paradox" }, { "count": 1, - "name": "Golgari Signet" + "name": "Urban Evolution" }, { "count": 1, - "name": "Talisman of Resilience" + "name": "Imoti, Celebrant of Bounty" }, { "count": 1, - "name": "Tooth and Nail" + "name": "Gush" }, { "count": 1, - "name": "Insidious Roots" + "name": "Lorien Revealed" }, { "count": 1, - "name": "Springleaf Parade" + "name": "Devastation Tide" }, { "count": 1, - "name": "Phyrexian Arena" + "name": "Step Between Worlds" }, { "count": 1, - "name": "Unbound Flourishing" + "name": "Natural Reclamation" }, { "count": 1, - "name": "Command Tower" + "name": "Aether Gale" }, { "count": 1, - "name": "Darkbore Pathway" + "name": "Rampaging Baloths" }, { "count": 1, - "name": "Llanowar Wastes" + "name": "Part the Waterveil" }, { "count": 1, - "name": "Overgrown Tomb" + "name": "Alrund's Epiphany" }, { "count": 1, - "name": "Reliquary Tower" + "name": "Temporal Mastery" }, { "count": 1, - "name": "Tainted Wood" - }, - { - "count": 1, - "name": "Temple of Malady" + "name": "Wisdom of Ages" }, { "count": 1, - "name": "Twilight Mire" + "name": "Aminatou's Augury" }, { "count": 1, - "name": "Vernal Fen" + "name": "Treasure Cruise" }, { "count": 1, - "name": "Viridescent Bog" - }, - { - "count": 12, - "name": "Forest" - }, - { - "count": 13, - "name": "Swamp" + "name": "Dig Through Time" }, { "count": 1, - "name": "Witherbloom, the Balancer" + "name": "Temporal Trespass" } ], "sideboard": [] }, { - "name": "Kinnan, Bonder Prodigy", + "name": "Prismari, the Inspiration", "author": "MTGGoldfish", "colors": [ "U", - "G" + "R" ], "tags": [ "metagame" @@ -1289,1144 +1317,1128 @@ "main": [ { "count": 1, - "name": "Amphibian Downpour" + "name": "Hellkite Courser" }, { "count": 1, - "name": "Ancient Tomb" + "name": "Geode Golem" }, { "count": 1, - "name": "Arcane Signet" + "name": "The Dawning Archaic" }, { "count": 1, - "name": "Barkchannel Pathway" + "name": "Third Path Iconoclast" }, { "count": 1, - "name": "Basalt Monolith" + "name": "Dragonspeaker Shaman" }, { "count": 1, - "name": "Birds of Paradise" + "name": "Runaway Steam-Kin" }, { "count": 1, - "name": "Borne Upon a Wind" + "name": "Goldspan Dragon" }, { "count": 1, - "name": "Boseiju, Who Endures" + "name": "Ragavan, Nimble Pilferer" }, { "count": 1, - "name": "Breeding Pool" + "name": "Dirgur Focusmage" }, { "count": 1, - "name": "Cephalid Coliseum" + "name": "Galazeth Prismari" }, { "count": 1, - "name": "Chain of Vapor" + "name": "Hexing Squelcher" }, { "count": 1, - "name": "Chord of Calling" + "name": "Snapcaster Mage" }, { "count": 1, - "name": "Chrome Mox" + "name": "Sprite Dragon" }, { "count": 1, - "name": "City of Brass" + "name": "Opt" }, { "count": 1, - "name": "Clever Impersonator" + "name": "Prismari Command" }, { "count": 1, - "name": "Command Tower" + "name": "Prismari Charm" }, { "count": 1, - "name": "Commandeer" + "name": "Brainstorm" }, { "count": 1, - "name": "Consecrated Sphinx" + "name": "Snap" }, { "count": 1, - "name": "Copy Artifact" + "name": "Gut Shot" }, { "count": 1, - "name": "Copy Enchantment" + "name": "Borne Upon a Wind" }, { "count": 1, - "name": "Disrupting Shoal" + "name": "High Tide" }, { "count": 1, - "name": "Dizzy Spell" + "name": "Dig Through Time" }, { "count": 1, - "name": "Dramatic Reversal" + "name": "Impulse" }, { "count": 1, - "name": "Drift of Phantasms" + "name": "Unexpected Windfall" }, { "count": 1, - "name": "Elvish Mystic" + "name": "Consider" }, { "count": 1, - "name": "Elvish Spirit Guide" + "name": "Abjure" }, { "count": 1, - "name": "Endurance" + "name": "Turn Aside" }, { "count": 1, - "name": "Enduring Vitality" + "name": "Wash Away" }, { "count": 1, - "name": "Faerie Mastermind" + "name": "Disruption Protocol" }, { "count": 1, - "name": "Fellwar Stone" + "name": "Familiar's Ruse" }, { "count": 1, - "name": "Fierce Guardianship" + "name": "Arcane Denial" }, { "count": 1, - "name": "Finale of Devastation" + "name": "Mana Drain" }, { "count": 1, - "name": "Flash Photography" + "name": "Rapturous Moment" }, { "count": 1, - "name": "Flesh Duplicate" + "name": "Splatter Technique" }, { "count": 1, - "name": "Flooded Strand" + "name": "Mathemagics" }, { "count": 1, - "name": "Flusterstorm" + "name": "Time Warp" }, { "count": 1, - "name": "Force of Negation" + "name": "Crackle with Power" }, { "count": 1, - "name": "Force of Will" + "name": "Merchant Scroll" }, { "count": 1, - "name": "Forest" + "name": "Fireball" }, { "count": 1, - "name": "Fyndhorn Elves" + "name": "Preordain" }, { "count": 1, - "name": "Gemstone Caverns" + "name": "Stock Up" }, { "count": 1, - "name": "Gene Pollinator" + "name": "Imposing Grandeur" }, { "count": 1, - "name": "Grim Monolith" + "name": "Ponder" }, { "count": 1, - "name": "Hidden Strings" + "name": "Time Spiral" }, { "count": 1, - "name": "High Fae Trickster" + "name": "Echo of Eons" }, { "count": 1, - "name": "Hullbreaker Horror" + "name": "Grapeshot" }, { "count": 1, - "name": "Hydroelectric Specimen" + "name": "Timetwister" }, { "count": 1, - "name": "Imposter Mech" + "name": "Commit // Memory" }, { "count": 1, - "name": "Invasion of Ikoria" + "name": "Campfire" }, { "count": 1, - "name": "Island" + "name": "The Immortal Sun" }, { "count": 1, - "name": "Llanowar Elves" + "name": "Sapphire Medallion" }, { "count": 1, - "name": "Lotus Petal" + "name": "Resonating Lute" }, { "count": 1, - "name": "Mana Confluence" + "name": "The Fire Crystal" }, { "count": 1, - "name": "Mana Vault" + "name": "Doubling Cube" }, { "count": 1, - "name": "Mental Misstep" + "name": "Levitating Statue" }, { "count": 1, - "name": "Mindbreak Trap" + "name": "Arcane Signet" }, { "count": 1, - "name": "Mirage Mirror" + "name": "Thought Vessel" }, { "count": 1, - "name": "Mirrormade" + "name": "Tablet of Discovery" }, { "count": 1, - "name": "Misdirection" + "name": "Commander's Sphere" }, { "count": 1, - "name": "Misty Rainforest" + "name": "Fellwar Stone" }, { "count": 1, - "name": "Mockingbird" + "name": "Ruby Medallion" }, { "count": 1, - "name": "Mox Amber" + "name": "Mindsplice Apparatus" }, { "count": 1, - "name": "Mox Diamond" + "name": "Propaganda" }, { "count": 1, - "name": "Mox Opal" + "name": "Omniscience" }, { "count": 1, - "name": "Mystic Remora" + "name": "Case of the Ransacked Lab" }, { "count": 1, - "name": "Mystical Tutor" + "name": "Cavern of Souls" }, { "count": 1, - "name": "Nature's Rhythm" + "name": "Command Beacon" }, { "count": 1, - "name": "Nezahal, Primal Tide" + "name": "Reliquary Tower" }, { "count": 1, - "name": "Otawara, Soaring City" + "name": "Mystic Sanctuary" }, { "count": 1, - "name": "Pact of Negation" + "name": "Mistrise Village" }, { "count": 1, - "name": "Phyrexian Metamorph" + "name": "Myriad Landscape" }, { "count": 1, - "name": "Polluted Delta" + "name": "Prismari Campus" }, { "count": 1, - "name": "Rejuvenating Springs" + "name": "Thundering Falls" }, { "count": 1, - "name": "Rhystic Study" + "name": "Cascade Bluffs" }, { "count": 1, - "name": "Scalding Tarn" + "name": "Gemstone Caverns" }, { "count": 1, - "name": "Sink into Stupor" + "name": "Volcanic Island" }, { "count": 1, - "name": "Snapback" + "name": "Training Center" }, { "count": 1, - "name": "Sol Ring" + "name": "Scorched Geyser" }, { "count": 1, - "name": "Springleaf Drum" + "name": "Scalding Tarn" }, { "count": 1, - "name": "Subtlety" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Swan Song" + "name": "Frostboil Snarl" }, { "count": 1, - "name": "Talisman of Curiosity" + "name": "Stormcarved Coast" }, { "count": 1, - "name": "Tarnished Citadel" + "name": "Coastal Peak" }, { "count": 1, - "name": "The Cabbage Merchant" + "name": "Reflecting Pool" }, { "count": 1, - "name": "The Unagi of Kyoshi Island" + "name": "Command Tower" }, { - "count": 1, - "name": "Thrasios, Triton Hero" + "count": 9, + "name": "Island" }, { - "count": 1, - "name": "Transmute Artifact" + "count": 5, + "name": "Mountain" }, { "count": 1, - "name": "Trophy Mage" - }, + "name": "Prismari, the Inspiration" + } + ], + "sideboard": [] + }, + { + "name": "Edgar Markov", + "author": "MTGGoldfish", + "colors": [ + "W", + "R", + "B" + ], + "tags": [ + "metagame" + ], + "main": [ { "count": 1, - "name": "Tropical Island" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Valley Floodcaller" + "name": "Path to Exile" }, { "count": 1, - "name": "Veil of Summer" + "name": "Foreboding Ruins" }, { "count": 1, - "name": "Verdant Catacombs" + "name": "Sol Ring" }, { "count": 1, - "name": "Wan Shi Tong, Librarian" + "name": "Arcane Signet" }, { "count": 1, - "name": "Waterlogged Grove" + "name": "Generous Gift" }, { "count": 1, - "name": "Whir of Invention" + "name": "Lethal Scheme" }, { "count": 1, - "name": "Windswept Heath" + "name": "Blasphemous Act" }, { "count": 1, - "name": "Wooded Foothills" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Worldly Tutor" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Yavimaya Coast" + "name": "Command Tower" }, { "count": 1, - "name": "Kinnan, Bonder Prodigy" - } - ], - "sideboard": [] - }, - { - "name": "Prismari, the Inspiration", - "author": "MTGGoldfish", - "colors": [ - "R", - "U" - ], - "tags": [ - "metagame" - ], - "main": [ - { - "count": 1, - "name": "Bulk Up" + "name": "Unclaimed Territory" }, { "count": 1, - "name": "Traumatic Critique" + "name": "Battlefield Forge" }, { "count": 1, - "name": "Temple of Epiphany" + "name": "Caves of Koilos" }, { "count": 1, - "name": "Prismari Campus" + "name": "Tainted Field" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Voldaren Estate" }, { "count": 1, - "name": "Expressive Iteration" + "name": "Dragonskull Summit" }, { "count": 1, - "name": "Study Hall" + "name": "Isolated Chapel" }, { "count": 1, - "name": "Spectacle Summit" + "name": "Vault of the Archangel" }, { "count": 1, - "name": "Dirgur Focusmage" + "name": "Fetid Heath" }, { "count": 1, - "name": "Manaform Hellkite" + "name": "Smoldering Marsh" }, { "count": 1, - "name": "Shivan Reef" + "name": "Secluded Courtyard" }, { "count": 1, - "name": "Consult the Star Charts" + "name": "Nomad Outpost" }, { "count": 1, - "name": "Blazing Firesinger" + "name": "Canyon Slough" }, { "count": 1, - "name": "Cascade Bluffs" + "name": "Shineshadow Snarl" }, { "count": 1, - "name": "Temple of the False God" + "name": "Sulfurous Springs" }, { "count": 1, - "name": "Archmage Emeritus" + "name": "Stroke of Midnight" }, { "count": 1, - "name": "Shark Typhoon" + "name": "Tainted Peak" }, { "count": 1, - "name": "Sulfur Falls" + "name": "Edgar Markov" }, { "count": 1, - "name": "Command Tower" + "name": "Clifftop Retreat" }, { - "count": 1, - "name": "Opt" + "count": 4, + "name": "Plains" }, { - "count": 1, - "name": "Tablet of Discovery" + "count": 4, + "name": "Mountain" }, { - "count": 1, - "name": "An Offer You Can't Refuse" + "count": 9, + "name": "Swamp" }, { "count": 1, - "name": "Goblin Glasswright" + "name": "Ruinous Ultimatum" }, { "count": 1, - "name": "Abstract Paintmage" + "name": "Village Rites" }, { "count": 1, - "name": "Rewind" + "name": "Corrupted Conviction" }, { "count": 1, - "name": "Stock Up" + "name": "Vampire Cutthroat" }, { "count": 1, - "name": "Resonating Lute" + "name": "Stromkirk Noble" }, { "count": 1, - "name": "Emeritus of Ideation" + "name": "Pulse Tracker" }, { "count": 1, - "name": "Rapturous Moment" + "name": "Master of Dark Rites" }, { "count": 1, - "name": "Flashback" + "name": "Knight of the Ebon Legion" }, { "count": 1, - "name": "Big Score" + "name": "Indulgent Aristocrat" }, { "count": 1, - "name": "Creative Technique" + "name": "Gas Guzzler" }, { "count": 1, - "name": "Ferrous Lake" + "name": "Legion Lieutenant" }, { "count": 1, - "name": "Volcanic Torrent" + "name": "Dusk Legion Zealot" }, { "count": 1, - "name": "The Emperor of Palamecia" + "name": "Cruel Celebrant" }, { "count": 1, - "name": "Master the Way" + "name": "Cordial Vampire" }, { "count": 1, - "name": "Prismari Charm" + "name": "Blood Artist" }, { "count": 1, - "name": "Rousing Refrain" + "name": "Voldaren Epicure" }, { "count": 1, - "name": "Pinnacle Monk" + "name": "Viscera Seer" }, { "count": 1, - "name": "Beacon of Tomorrows" + "name": "Vicious Conquistador" }, { "count": 1, - "name": "Reliquary Tower" + "name": "Florian, Voldaren Scion" }, { "count": 1, - "name": "Arcane Signet" + "name": "Susurian Voidborn" }, { "count": 1, - "name": "Teach by Example" + "name": "Stromkirk Captain" }, { "count": 1, - "name": "Prismari Command" + "name": "Ruthless Lawbringer" }, { "count": 1, - "name": "Fellwar Stone" + "name": "Qarsi Revenant" }, { "count": 1, - "name": "Abrade" + "name": "Preacher of the Schism" }, { "count": 1, - "name": "Sol Ring" + "name": "Mirror Entity" }, { "count": 1, - "name": "Seize the Spoils" + "name": "Markov Baron" }, { "count": 1, - "name": "Coastal Peak" + "name": "Drana, Liberator of Malakir" }, { "count": 1, - "name": "Comet Storm" + "name": "Clavileno, First of the Blessed" }, { "count": 1, - "name": "Sanar, Unfinished Genius" + "name": "Captivating Vampire" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Vengeful Bloodwitch" }, { "count": 1, - "name": "Crackle with Power" + "name": "Vampire Socialite" }, { "count": 1, - "name": "Terramorphic Expanse" + "name": "Vampire Gourmand" }, { "count": 1, - "name": "Talisman of Creativity" + "name": "Welcoming Vampire" }, { "count": 1, - "name": "Colorstorm Stallion" + "name": "Falkenrath Noble" }, { "count": 1, - "name": "Furygale Flocking" + "name": "Sanctum Seeker" }, { "count": 1, - "name": "Scorched Geyser" + "name": "Champion of Dusk" }, { "count": 1, - "name": "Storm-Kiln Artist" + "name": "Malakir Bloodwitch" }, { "count": 1, - "name": "Stormcatch Mentor" + "name": "Bartolome del Presidio" }, { "count": 1, - "name": "Harmonized Trio" + "name": "Bloodthrone Vampire" }, { "count": 1, - "name": "Fabled Passage" + "name": "Goblin Bombardment" }, { "count": 1, - "name": "Mana Geyser" + "name": "Skullclamp" }, { "count": 1, - "name": "Dualcaster Mage" + "name": "Patchwork Banner" }, { "count": 1, - "name": "Snap" + "name": "Deadly Dispute" }, { "count": 1, - "name": "Frantic Search" + "name": "Plumb the Forbidden" }, { "count": 1, - "name": "Refute" + "name": "Fracture" }, { "count": 1, - "name": "Leitmotif Composer" + "name": "Vindicate" }, { "count": 1, - "name": "Jaya's Immolating Inferno" + "name": "Despark" }, { "count": 1, - "name": "Zaffai and the Tempests" + "name": "Invasion of New Capenna" }, { "count": 1, - "name": "Flare of Duplication" + "name": "Night's Whisper" }, { "count": 1, - "name": "Electro, Assaulting Battery" + "name": "Stromkirk Condemned" }, { "count": 1, - "name": "Hall of Oracles" + "name": "Crossway Troublemakers" }, { "count": 1, - "name": "Mystic Sanctuary" + "name": "Gathering Stone" }, { "count": 1, - "name": "River's Rebuke" - }, + "name": "Rite of Oblivion" + } + ], + "sideboard": [] + }, + { + "name": "Kinnan, Bonder Prodigy", + "author": "MTGGoldfish", + "colors": [ + "U", + "G" + ], + "tags": [ + "metagame" + ], + "main": [ { "count": 1, - "name": "Exotic Orchard" + "name": "Amphibian Downpour" }, { "count": 1, - "name": "Dig Through Time" + "name": "Ancient Tomb" }, { "count": 1, - "name": "Frostboil Snarl" + "name": "Arcane Signet" }, { "count": 1, - "name": "Turbulent Springs" + "name": "Barkchannel Pathway" }, { "count": 1, - "name": "Restless Spire" + "name": "Basalt Monolith" }, { "count": 1, - "name": "Decanter of Endless Water" + "name": "Birds of Paradise" }, { "count": 1, - "name": "Chaos Warp" + "name": "Borne Upon a Wind" }, { "count": 1, - "name": "Replication Technique" + "name": "Boseiju, Who Endures" }, { "count": 1, - "name": "Arcane Denial" + "name": "Breeding Pool" }, { "count": 1, - "name": "Molten Tributary" + "name": "Cephalid Coliseum" }, { "count": 1, - "name": "Sundering Eruption" + "name": "Chain of Vapor" }, { "count": 1, - "name": "Counterspell" + "name": "Chord of Calling" }, { "count": 1, - "name": "Sunbird's Invocation" + "name": "Chrome Mox" }, { "count": 1, - "name": "Thousand-Year Storm" - }, - { - "count": 5, - "name": "Mountain" - }, - { - "count": 5, - "name": "Island" + "name": "City of Brass" }, { "count": 1, - "name": "Prismari, the Inspiration" - } - ], - "sideboard": [] - }, - { - "name": "Edgar Markov", - "author": "MTGGoldfish", - "colors": [ - "W", - "R", - "B" - ], - "tags": [ - "metagame" - ], - "main": [ - { - "count": 1, - "name": "Swords to Plowshares" + "name": "Clever Impersonator" }, { "count": 1, - "name": "Path to Exile" + "name": "Command Tower" }, { "count": 1, - "name": "Foreboding Ruins" + "name": "Commandeer" }, { "count": 1, - "name": "Sol Ring" + "name": "Consecrated Sphinx" }, { "count": 1, - "name": "Arcane Signet" + "name": "Copy Artifact" }, { "count": 1, - "name": "Generous Gift" + "name": "Copy Enchantment" }, { "count": 1, - "name": "Lethal Scheme" + "name": "Disrupting Shoal" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Dizzy Spell" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Dramatic Reversal" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Drift of Phantasms" }, { "count": 1, - "name": "Command Tower" + "name": "Elvish Mystic" }, { "count": 1, - "name": "Unclaimed Territory" + "name": "Elvish Spirit Guide" }, { "count": 1, - "name": "Battlefield Forge" + "name": "Endurance" }, { "count": 1, - "name": "Caves of Koilos" + "name": "Enduring Vitality" }, { "count": 1, - "name": "Tainted Field" + "name": "Faerie Mastermind" }, { "count": 1, - "name": "Voldaren Estate" + "name": "Fellwar Stone" }, { "count": 1, - "name": "Dragonskull Summit" + "name": "Fierce Guardianship" }, { "count": 1, - "name": "Isolated Chapel" + "name": "Finale of Devastation" }, { "count": 1, - "name": "Vault of the Archangel" + "name": "Flash Photography" }, { "count": 1, - "name": "Fetid Heath" + "name": "Flesh Duplicate" }, { "count": 1, - "name": "Smoldering Marsh" + "name": "Flooded Strand" }, { "count": 1, - "name": "Secluded Courtyard" + "name": "Flusterstorm" }, { "count": 1, - "name": "Nomad Outpost" + "name": "Force of Negation" }, { "count": 1, - "name": "Canyon Slough" + "name": "Force of Will" }, { "count": 1, - "name": "Shineshadow Snarl" + "name": "Forest" }, { "count": 1, - "name": "Sulfurous Springs" + "name": "Fyndhorn Elves" }, { "count": 1, - "name": "Stroke of Midnight" + "name": "Gemstone Caverns" }, { "count": 1, - "name": "Tainted Peak" + "name": "Gene Pollinator" }, { "count": 1, - "name": "Edgar Markov" + "name": "Grim Monolith" }, { "count": 1, - "name": "Clifftop Retreat" + "name": "Hidden Strings" }, { - "count": 4, - "name": "Plains" + "count": 1, + "name": "High Fae Trickster" }, { - "count": 4, - "name": "Mountain" + "count": 1, + "name": "Hullbreaker Horror" }, { - "count": 9, - "name": "Swamp" + "count": 1, + "name": "Hydroelectric Specimen" }, { "count": 1, - "name": "Ruinous Ultimatum" + "name": "Imposter Mech" }, { "count": 1, - "name": "Village Rites" + "name": "Invasion of Ikoria" }, { "count": 1, - "name": "Corrupted Conviction" + "name": "Island" }, { "count": 1, - "name": "Vampire Cutthroat" + "name": "Llanowar Elves" }, { "count": 1, - "name": "Stromkirk Noble" + "name": "Lotus Petal" }, { "count": 1, - "name": "Pulse Tracker" + "name": "Mana Confluence" }, { "count": 1, - "name": "Master of Dark Rites" + "name": "Mana Vault" }, { "count": 1, - "name": "Knight of the Ebon Legion" + "name": "Mental Misstep" }, { "count": 1, - "name": "Indulgent Aristocrat" + "name": "Mindbreak Trap" }, { "count": 1, - "name": "Gas Guzzler" + "name": "Mirage Mirror" }, { "count": 1, - "name": "Legion Lieutenant" + "name": "Mirrormade" }, { "count": 1, - "name": "Dusk Legion Zealot" + "name": "Misdirection" }, { "count": 1, - "name": "Cruel Celebrant" + "name": "Misty Rainforest" }, { "count": 1, - "name": "Cordial Vampire" + "name": "Mockingbird" }, { "count": 1, - "name": "Blood Artist" + "name": "Mox Amber" }, { "count": 1, - "name": "Voldaren Epicure" + "name": "Mox Diamond" }, { "count": 1, - "name": "Viscera Seer" + "name": "Mox Opal" }, { "count": 1, - "name": "Vicious Conquistador" + "name": "Mystic Remora" }, { "count": 1, - "name": "Florian, Voldaren Scion" + "name": "Mystical Tutor" }, { "count": 1, - "name": "Susurian Voidborn" + "name": "Nature's Rhythm" }, { "count": 1, - "name": "Stromkirk Captain" + "name": "Nezahal, Primal Tide" }, { "count": 1, - "name": "Ruthless Lawbringer" + "name": "Otawara, Soaring City" }, { "count": 1, - "name": "Qarsi Revenant" + "name": "Pact of Negation" }, { "count": 1, - "name": "Preacher of the Schism" + "name": "Phyrexian Metamorph" }, { "count": 1, - "name": "Mirror Entity" + "name": "Polluted Delta" }, { "count": 1, - "name": "Markov Baron" + "name": "Rejuvenating Springs" }, { "count": 1, - "name": "Drana, Liberator of Malakir" + "name": "Rhystic Study" }, { "count": 1, - "name": "Clavileno, First of the Blessed" + "name": "Scalding Tarn" }, { "count": 1, - "name": "Captivating Vampire" + "name": "Sink into Stupor" }, { "count": 1, - "name": "Vengeful Bloodwitch" + "name": "Snapback" }, { "count": 1, - "name": "Vampire Socialite" + "name": "Sol Ring" }, { "count": 1, - "name": "Vampire Gourmand" + "name": "Springleaf Drum" }, { "count": 1, - "name": "Welcoming Vampire" + "name": "Subtlety" }, { "count": 1, - "name": "Falkenrath Noble" + "name": "Swan Song" }, { "count": 1, - "name": "Sanctum Seeker" + "name": "Talisman of Curiosity" }, { "count": 1, - "name": "Champion of Dusk" + "name": "Tarnished Citadel" }, { "count": 1, - "name": "Malakir Bloodwitch" + "name": "The Cabbage Merchant" }, { "count": 1, - "name": "Bartolome del Presidio" + "name": "The Unagi of Kyoshi Island" }, { "count": 1, - "name": "Bloodthrone Vampire" + "name": "Thrasios, Triton Hero" }, { "count": 1, - "name": "Goblin Bombardment" + "name": "Transmute Artifact" }, { "count": 1, - "name": "Skullclamp" + "name": "Trophy Mage" }, { "count": 1, - "name": "Patchwork Banner" + "name": "Tropical Island" }, { "count": 1, - "name": "Deadly Dispute" + "name": "Valley Floodcaller" }, { "count": 1, - "name": "Plumb the Forbidden" + "name": "Veil of Summer" }, { "count": 1, - "name": "Fracture" + "name": "Verdant Catacombs" }, { "count": 1, - "name": "Vindicate" + "name": "Wan Shi Tong, Librarian" }, { "count": 1, - "name": "Despark" + "name": "Waterlogged Grove" }, { "count": 1, - "name": "Invasion of New Capenna" + "name": "Whir of Invention" }, { "count": 1, - "name": "Night's Whisper" + "name": "Windswept Heath" }, { "count": 1, - "name": "Stromkirk Condemned" + "name": "Wooded Foothills" }, { "count": 1, - "name": "Crossway Troublemakers" + "name": "Worldly Tutor" }, { "count": 1, - "name": "Gathering Stone" + "name": "Yavimaya Coast" }, { "count": 1, - "name": "Rite of Oblivion" + "name": "Kinnan, Bonder Prodigy" } ], "sideboard": [] @@ -2436,8 +2448,8 @@ "author": "MTGGoldfish", "colors": [ "R", - "G", - "W" + "W", + "G" ], "tags": [ "metagame" @@ -2445,15 +2457,7 @@ "main": [ { "count": 1, - "name": "Guardian Project" - }, - { - "count": 1, - "name": "Branching Evolution" - }, - { - "count": 1, - "name": "Elemental Teachings" + "name": "Earthbending Lesson" }, { "count": 1, @@ -2461,27 +2465,23 @@ }, { "count": 1, - "name": "Mightform Harmonizer" - }, - { - "count": 1, - "name": "Horizon Explorer" + "name": "Path to Redemption" }, { "count": 1, - "name": "Cracked Earth Technique" + "name": "Earth Kingdom Jailer" }, { "count": 1, - "name": "Bumi, Eclectic Earthbender" + "name": "Kyoshi Battle Fan" }, { "count": 1, - "name": "Haru, Hidden Talent" + "name": "Fated Firepower" }, { "count": 1, - "name": "Mind Stone" + "name": "Yip Yip!" }, { "count": 1, @@ -2489,27 +2489,7 @@ }, { "count": 1, - "name": "Master's Guidance" - }, - { - "count": 1, - "name": "Selesnya Cluestone" - }, - { - "count": 1, - "name": "Swords to Plowshares" - }, - { - "count": 1, - "name": "Genesis Wave" - }, - { - "count": 1, - "name": "Avatar Kyoshi, Earthbender" - }, - { - "count": 1, - "name": "Zuran Orb" + "name": "Haru, Hidden Talent" }, { "count": 1, @@ -2517,123 +2497,123 @@ }, { "count": 1, - "name": "Avenger of Zendikar" + "name": "Fervor" }, { "count": 1, - "name": "Earth Rumble" + "name": "Sandbenders' Storm" }, { "count": 1, - "name": "Boros Cluestone" + "name": "Bitter Work" }, { "count": 1, - "name": "Crystalline Armor" + "name": "Energybending" }, { "count": 1, - "name": "Omnath, Locus of Rage" + "name": "Team Avatar" }, { "count": 1, - "name": "Sazh's Chocobo" + "name": "Leaves from the Vine" }, { "count": 1, - "name": "Moraug, Fury of Akoum" + "name": "Shared Roots" }, { "count": 1, - "name": "Garruk's Uprising" + "name": "Earth Rumble" }, { "count": 1, - "name": "Sabotender" + "name": "Aang's Iceberg" }, { "count": 1, - "name": "Arcane Signet" + "name": "Fancy Footwork" }, { "count": 1, - "name": "Bumi, Unleashed" + "name": "Earthbender Ascension" }, { "count": 1, - "name": "Earthbender Ascension" + "name": "Raucous Audience" }, { "count": 1, - "name": "Ashaya, Soul of the Wild" + "name": "Aang's Journey" }, { "count": 1, - "name": "The Legend of Kyoshi" + "name": "Seismic Sense" }, { "count": 1, - "name": "Gruul Cluestone" + "name": "Rebellious Captives" }, { "count": 1, - "name": "Teleportation Circle" + "name": "Origin of Metalbending" }, { "count": 1, - "name": "Ouroboroid" + "name": "Twin Blades" }, { "count": 1, - "name": "The Boulder, Ready to Rumble" + "name": "Enter the Avatar State" }, { "count": 1, - "name": "Aang's Journey" + "name": "The Earth King" }, { "count": 1, - "name": "Sol Ring" + "name": "Earth Kingdom General" }, { "count": 1, - "name": "Thought Vessel" + "name": "Rocky Rebuke" }, { "count": 1, - "name": "Sandbenders' Storm" + "name": "Pillar Launch" }, { "count": 1, - "name": "Cycle of Renewal" + "name": "The Cave of Two Lovers" }, { "count": 1, - "name": "Rocco, Cabaretti Caterer" + "name": "Rockalanche" }, { "count": 1, - "name": "Earth Kingdom General" + "name": "Bumi, Unleashed" }, { "count": 1, - "name": "Toph, Earthbending Master" + "name": "Cycle of Renewal" }, { "count": 1, - "name": "Terrasymbiosis" + "name": "Bumi, King of Three Trials" }, { "count": 1, - "name": "Bitter Work" + "name": "Toph, Hardheaded Teacher" }, { "count": 1, - "name": "Blossoming Sands" + "name": "Great Divide Guide" }, { "count": 1, - "name": "Clifftop Retreat" + "name": "Razor Rings" }, { "count": 1, @@ -2641,139 +2621,127 @@ }, { "count": 1, - "name": "Rogue's Passage" + "name": "Secret Tunnel" }, { "count": 1, - "name": "Command Tower" + "name": "Kyoshi Village" }, { "count": 1, - "name": "Vibrant Cityscape" + "name": "Sun-Blessed Peak" }, { "count": 1, - "name": "Stomping Ground" + "name": "White Lotus Hideout" }, { "count": 1, - "name": "Secret Tunnel" + "name": "Omashu City" }, { "count": 1, - "name": "Kyoshi Village" - }, - { - "count": 7, - "name": "Forest" - }, - { - "count": 6, - "name": "Plains" - }, - { - "count": 7, - "name": "Mountain" + "name": "Jasmine Dragon Tea Shop" }, { "count": 1, - "name": "Toph, the First Metalbender" + "name": "Rumble Arena" }, { "count": 1, - "name": "Annie Joins Up" + "name": "Treetop Village" }, { "count": 1, - "name": "Sphere Grid" + "name": "Fire Nation Palace" }, { "count": 1, - "name": "The Earth Crystal" + "name": "Abandoned Air Temple" }, { - "count": 1, - "name": "Path to Exile" + "count": 10, + "name": "Mountain" }, { - "count": 1, - "name": "Beast Within" + "count": 10, + "name": "Plains" }, { - "count": 1, - "name": "Darksteel Citadel" + "count": 11, + "name": "Forest" }, { "count": 1, - "name": "Lotus Field" + "name": "Toph, the First Metalbender" }, { "count": 1, - "name": "Fabled Passage" + "name": "Thriving Heath" }, { "count": 1, - "name": "Hour of Revelation" + "name": "Thriving Grove" }, { "count": 1, - "name": "Bootleggers' Stash" + "name": "Thriving Bluff" }, { "count": 1, - "name": "Titania, Protector of Argoth" + "name": "Crystalline Armor" }, { "count": 1, - "name": "Chromatic Lantern" + "name": "Cracked Earth Technique" }, { "count": 1, - "name": "Sleeper Dart" + "name": "The Boulder, Ready to Rumble" }, { "count": 1, - "name": "Tannuk, Memorial Ensign" + "name": "Bosco, Just a Bear" }, { "count": 1, - "name": "Nature's Lore" + "name": "Kyoshi Island Plaza" }, { "count": 1, - "name": "Liquimetal Torque" + "name": "Earthshape" }, { "count": 1, - "name": "Eumidian Terrabotanist" + "name": "Avatar Kyoshi" }, { "count": 1, - "name": "Evolution Sage" + "name": "Bumi, Eclectic Earthbender" }, { "count": 1, - "name": "All Will Be One" + "name": "The Legend of Kyoshi" }, { "count": 1, - "name": "Mountain Valley" + "name": "Tale of Katara and Toph" }, { "count": 1, - "name": "Earthshape" + "name": "Solid Ground" }, { "count": 1, - "name": "Canopy Vista" + "name": "Swampbenders" }, { "count": 1, - "name": "Cinder Glade" + "name": "Master's Guidance" }, { "count": 1, - "name": "Radiant Summit" + "name": "Inspiring Call" } ], "sideboard": [] @@ -3097,12 +3065,11 @@ "sideboard": [] }, { - "name": "Kaalia of the Vast", + "name": "The Scarab God", "author": "MTGGoldfish", "colors": [ - "B", - "W", - "R" + "U", + "B" ], "tags": [ "metagame" @@ -3110,79 +3077,27 @@ "main": [ { "count": 1, - "name": "Ancient Copper Dragon" - }, - { - "count": 1, - "name": "Ancient Tomb" - }, - { - "count": 1, - "name": "Angel of Despair" - }, - { - "count": 1, - "name": "Angel of Serenity" - }, - { - "count": 1, - "name": "Angelic Arbiter" - }, - { - "count": 1, - "name": "Anguished Unmaking" - }, - { - "count": 1, - "name": "Animate Dead" - }, - { - "count": 1, - "name": "Arcane Signet" - }, - { - "count": 1, - "name": "Archfiend of Depravity" + "name": "Lord of the Undead" }, { "count": 1, - "name": "Archfiend of Despair" + "name": "Sunken Ruins" }, { "count": 1, - "name": "Arena of Glory" + "name": "Mind Grind" }, { "count": 1, - "name": "Arid Mesa" - }, - { - "count": 1, - "name": "Aurelia, the Warleader" - }, - { - "count": 1, - "name": "Avacyn, Angel of Hope" - }, - { - "count": 1, - "name": "Badlands" - }, - { - "count": 1, - "name": "Balefire Dragon" - }, - { - "count": 1, - "name": "Blasphemous Act" + "name": "Phyrexian Altar" }, { "count": 1, - "name": "Blood Crypt" + "name": "Thespian's Stage" }, { "count": 1, - "name": "Bloodstained Mire" + "name": "Roaming Throne" }, { "count": 1, @@ -3190,11 +3105,11 @@ }, { "count": 1, - "name": "Caves of Koilos" + "name": "Bloodletter of Aclazotz" }, { "count": 1, - "name": "Clifftop Retreat" + "name": "Demonic Tutor" }, { "count": 1, @@ -3202,314 +3117,291 @@ }, { "count": 1, - "name": "Damn" - }, - { - "count": 1, - "name": "Dark Ritual" - }, - { - "count": 1, - "name": "Deadly Rollick" - }, - { - "count": 1, - "name": "Deflecting Swat" - }, - { - "count": 1, - "name": "Demonic Counsel" - }, - { - "count": 1, - "name": "Demonic Tutor" - }, - { - "count": 1, - "name": "Dragon Tempest" + "name": "Death Baron" }, { "count": 1, - "name": "Dragonskull Summit" + "name": "Diregraf Captain" }, { "count": 1, - "name": "Drakuseth, Maw of Flames" + "name": "Diregraf Colossus" }, { "count": 1, - "name": "Drana and Linvala" + "name": "Drowned Catacomb" }, { "count": 1, - "name": "Enlightened Tutor" + "name": "Ghoulcaller Gisa" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Wonder" }, { "count": 1, - "name": "Farewell" + "name": "The Ghoul, Gunslinger" }, { "count": 1, - "name": "Flawless Maneuver" + "name": "Memory Erosion" }, { "count": 1, - "name": "Gisela, Blade of Goldnight" + "name": "Geralf, the Fleshwright" }, { "count": 1, - "name": "Godless Shrine" + "name": "Stitcher Geralf" }, { "count": 1, - "name": "Grand Abolisher" + "name": "Gravecrawler" }, { "count": 1, - "name": "Hellkite Tyrant" + "name": "Gray Merchant of Asphodel" }, { - "count": 1, - "name": "Isolated Chapel" + "count": 10, + "name": "Island" }, { "count": 1, - "name": "Isshin, Two Heavens as One" + "name": "Liliana of the Dark Realms" }, { "count": 1, - "name": "Jeska's Will" + "name": "Liliana, Dreadhorde General" }, { "count": 1, - "name": "Kaalia, Zenith Seeker" + "name": "Profane Tutor" }, { "count": 1, - "name": "Land Tax" + "name": "Buried Alive" }, { "count": 1, - "name": "Liesa, Forgotten Archangel" + "name": "Singularity Rupture" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Empty the Laboratory" }, { "count": 1, - "name": "Lord of the Void" + "name": "Morphic Pool" }, { "count": 1, - "name": "Marsh Flats" + "name": "Nykthos, Shrine to Nyx" }, { "count": 1, - "name": "Master of Cruelties" + "name": "Nyx Lotus" }, { "count": 1, - "name": "Minas Tirith" + "name": "Phyrexian Tower" }, { "count": 1, - "name": "Mortify" + "name": "Sol Ring" }, { "count": 1, - "name": "Mother of Runes" + "name": "Phenax, God of Deception" }, { "count": 1, - "name": "Mountain" + "name": "Corpse Harvester" }, { - "count": 1, - "name": "Night's Whisper" + "count": 12, + "name": "Swamp" }, { "count": 1, - "name": "Nomad Outpost" + "name": "Undead Warchief" }, { "count": 1, - "name": "Orzhov Signet" + "name": "Unholy Grotto" }, { "count": 1, - "name": "Path to Exile" + "name": "Victimize" }, { "count": 1, - "name": "Plains" + "name": "Zombie Master" }, { "count": 1, - "name": "Plateau" + "name": "Relentless Dead" }, { "count": 1, - "name": "Rakdos, Patron of Chaos" + "name": "Wilhelt, the Rotcleaver" }, { "count": 1, - "name": "Razaketh, the Foulblooded" + "name": "Vizier of the Scorpion" }, { "count": 1, - "name": "Read the Bones" + "name": "Gleaming Overseer" }, { "count": 1, - "name": "Reanimate" + "name": "Cleaver Skaab" }, { "count": 1, - "name": "Reconnaissance" + "name": "Maddening Cacophony" }, { "count": 1, - "name": "Reliquary Tower" + "name": "Blasphemous Edict" }, { "count": 1, - "name": "Reya Dawnbringer" + "name": "Mindcrank" }, { "count": 1, - "name": "Rogue's Passage" + "name": "Bontu's Monument" }, { "count": 1, - "name": "Ruinous Ultimatum" + "name": "Bloodchief Ascension" }, { "count": 1, - "name": "Rune-Scarred Demon" + "name": "Jet Medallion" }, { "count": 1, - "name": "Sacred Foundry" + "name": "Psychic Spiral" }, { "count": 1, - "name": "Savai Triome" + "name": "Vault 12: The Necropolis" }, { "count": 1, - "name": "Scrubland" + "name": "Dread Summons" }, { "count": 1, - "name": "Sephara, Sky's Blade" + "name": "Black Market" }, { "count": 1, - "name": "Serra's Emissary" + "name": "Counterspell" }, { "count": 1, - "name": "Shattered Sanctum" + "name": "Training Grounds" }, { "count": 1, - "name": "Shizo, Death's Storehouse" + "name": "Dreadhorde Invasion" }, { "count": 1, - "name": "Smoldering Marsh" + "name": "Psychic Corrosion" }, { "count": 1, - "name": "Smothering Tithe" + "name": "Grave Pact" }, { "count": 1, - "name": "Sol Ring" + "name": "Necroduality" }, { "count": 1, - "name": "Sundown Pass" + "name": "Shipwreck Marsh" }, { "count": 1, - "name": "Swamp" + "name": "Cavern of Souls" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Liliana's Standard Bearer" }, { "count": 1, - "name": "Swords to Plowshares" + "name": "Grim Servant" }, { "count": 1, - "name": "Teferi's Protection" + "name": "Myriad Landscape" }, { "count": 1, - "name": "Temple of Malice" + "name": "Paradox Haze" }, { "count": 1, - "name": "Temple of Silence" + "name": "Zombie Apocalypse" }, { "count": 1, - "name": "Temple of Triumph" + "name": "Rise of the Dread Marn" }, { "count": 1, - "name": "Terminate" + "name": "Ayara, First of Locthwain" }, { "count": 1, - "name": "Terror of the Peaks" + "name": "Tormod, the Desecrator" }, { "count": 1, - "name": "The One Ring" + "name": "Archghoul of Thraben" }, { "count": 1, - "name": "Twinflame Tyrant" + "name": "Syr Konrad, the Grim" }, { "count": 1, - "name": "Urborg, Tomb of Yawgmoth" + "name": "Cryptbreaker" }, { "count": 1, - "name": "Valgavoth, Terror Eater" + "name": "Gisa and Geralf" }, { "count": 1, - "name": "Vampiric Tutor" + "name": "Teval's Judgment" }, { "count": 1, - "name": "Vault of the Archangel" + "name": "Screeching Scorchbeast" }, { "count": 1, - "name": "Vilis, Broker of Blood" + "name": "Rooftop Storm" }, { "count": 1, - "name": "Whispersilk Cloak" - }, + "name": "The Scarab God" + } + ], + "sideboard": [ { "count": 1, - "name": "Kaalia of the Vast" + "name": "Lim-Dul the Necromancer" } - ], - "sideboard": [] + ] } ] } \ No newline at end of file diff --git a/client/public/feeds/mtggoldfish-modern.json b/client/public/feeds/mtggoldfish-modern.json index 453a47abf7..4add7ed3dd 100644 --- a/client/public/feeds/mtggoldfish-modern.json +++ b/client/public/feeds/mtggoldfish-modern.json @@ -5,7 +5,7 @@ "icon": "G", "format": "modern", "version": 1, - "updated": "2026-06-08T00:00:00Z", + "updated": "2026-06-09T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/modern", "decks": [ { @@ -167,129 +167,129 @@ "main": [ { "count": 4, - "name": "Ruby Medallion" - }, - { - "count": 4, - "name": "Ral, Monsoon Mage" + "name": "Wrenn's Resolve" }, { - "count": 2, - "name": "Artist's Talent" + "count": 1, + "name": "Commercial District" }, { - "count": 2, - "name": "Wooded Foothills" + "count": 1, + "name": "Elegant Parlor" }, { - "count": 2, - "name": "Flashback" + "count": 4, + "name": "Ruby Medallion" }, { "count": 2, - "name": "Scalding Tarn" + "name": "Glimpse the Impossible" }, { "count": 4, - "name": "Pyretic Ritual" + "name": "Ral, Monsoon Mage" }, { "count": 2, - "name": "Valakut Awakening" + "name": "Artist's Talent" }, { "count": 2, - "name": "Glimpse the Impossible" + "name": "Flashback" + }, + { + "count": 4, + "name": "Desperate Ritual" }, { "count": 1, - "name": "Sacred Foundry" + "name": "Grapeshot" }, { - "count": 4, - "name": "Mountain" + "count": 1, + "name": "Gemstone Caverns" }, { "count": 4, - "name": "Reckless Impulse" + "name": "Manamorphose" }, { "count": 2, - "name": "Wish" + "name": "Scalding Tarn" }, { "count": 4, - "name": "Wrenn's Resolve" + "name": "Pyretic Ritual" }, { - "count": 1, - "name": "Gemstone Caverns" + "count": 3, + "name": "Past in Flames" }, { "count": 1, - "name": "Commercial District" + "name": "Sacred Foundry" }, { "count": 2, "name": "Bloodstained Mire" }, { - "count": 1, - "name": "Elegant Parlor" + "count": 2, + "name": "Wooded Foothills" }, { - "count": 3, - "name": "Arid Mesa" + "count": 4, + "name": "Mountain" }, { - "count": 3, - "name": "Past in Flames" + "count": 1, + "name": "Sunbaked Canyon" }, { - "count": 1, - "name": "Grapeshot" + "count": 2, + "name": "Valakut Awakening" }, { - "count": 4, - "name": "Manamorphose" + "count": 3, + "name": "Arid Mesa" }, { - "count": 1, - "name": "Sunbaked Canyon" + "count": 2, + "name": "Wish" }, { "count": 4, - "name": "Desperate Ritual" + "name": "Reckless Impulse" } ], "sideboard": [ { - "count": 1, - "name": "Past in Flames" - }, - { - "count": 4, - "name": "Orim's Chant" + "count": 2, + "name": "Vexing Bauble" }, { "count": 1, - "name": "Grapeshot" + "name": "Empty the Warrens" }, { "count": 1, - "name": "Empty the Warrens" + "name": "Grapeshot" }, { "count": 4, - "name": "Prismatic Ending" + "name": "Orim's Chant" }, { - "count": 2, - "name": "Vexing Bauble" + "count": 1, + "name": "Past in Flames" }, { "count": 2, "name": "Wear // Tear" + }, + { + "count": 4, + "name": "Prismatic Ending" } ] }, @@ -1088,141 +1088,138 @@ ] }, { - "name": "Eldrazi Ramp", + "name": "Grixis Reanimator", "author": "MTGGoldfish", "colors": [ - "R", - "G" + "B", + "U", + "R" ], "tags": [ "metagame" ], "main": [ { - "count": 1, - "name": "Commercial District" + "count": 3, + "name": "Unearth" }, { - "count": 4, - "name": "Sowing Mycospawn" + "count": 1, + "name": "Raucous Theater" }, { - "count": 3, - "name": "Devourer of Destiny" + "count": 1, + "name": "Thundering Falls" }, { - "count": 4, - "name": "Kozilek's Command" + "count": 1, + "name": "Undercity Sewers" }, { "count": 4, - "name": "Malevolent Rumble" + "name": "Emperor of Bones" }, { "count": 4, - "name": "Ugin's Labyrinth" + "name": "Psychic Frog" }, { "count": 1, - "name": "Sire of Seven Deaths" + "name": "Sink into Stupor" }, { - "count": 2, - "name": "Ugin, Eye of the Storms" + "count": 4, + "name": "Abhorrent Oculus" }, { "count": 2, - "name": "Icetill Explorer" + "name": "Quantum Riddler" }, { "count": 4, - "name": "Talisman of Impulse" + "name": "Thoughtseize" }, { "count": 1, - "name": "Ghost Quarter" + "name": "Scalding Tarn" }, { "count": 4, - "name": "Utopia Sprawl" + "name": "Faithless Looting" }, { "count": 1, - "name": "Bojuka Bog" + "name": "Blood Crypt" }, { - "count": 2, - "name": "Ancient Stirrings" + "count": 4, + "name": "Polluted Delta" }, { "count": 4, - "name": "Eldrazi Temple" + "name": "Bloodstained Mire" }, { - "count": 1, - "name": "Cavern of Souls" + "count": 4, + "name": "Fatal Push" }, { "count": 2, - "name": "Stomping Ground" + "name": "Spell Pierce" }, { - "count": 4, - "name": "Wooded Foothills" + "count": 1, + "name": "Steam Vents" }, { "count": 1, - "name": "Sanctum of Ugin" + "name": "Watery Grave" }, { - "count": 2, - "name": "Kozilek's Return" + "count": 1, + "name": "Island" }, { - "count": 1, - "name": "World Breaker" + "count": 2, + "name": "Swamp" }, { - "count": 3, - "name": "Emrakul, the Promised End" + "count": 4, + "name": "Archon of Cruelty" }, { - "count": 3, - "name": "Forest" + "count": 4, + "name": "Persist" }, { "count": 2, - "name": "Unholy Heat" + "name": "Otherworldly Gaze" } ], "sideboard": [ { - "count": 2, - "name": "Fade from History" + "count": 4, + "name": "Consign to Memory" }, { "count": 2, - "name": "The Stone Brain" - }, - { - "count": 3, "name": "Vexing Bauble" }, { "count": 2, - "name": "Trinisphere" + "name": "Pyroclasm" }, { - "count": 2, - "name": "Nature's Claim" + "count": 3, + "name": "Leyline of the Void" }, { "count": 2, - "name": "Blasphemous Act" + "name": "Damping Sphere" }, { "count": 2, - "name": "Grafdigger's Cage" + "name": "Mystical Dispute" } ] }, @@ -1370,138 +1367,141 @@ ] }, { - "name": "Grixis Reanimator", + "name": "Eldrazi Ramp", "author": "MTGGoldfish", "colors": [ - "B", - "U", - "R" + "R", + "G" ], "tags": [ "metagame" ], "main": [ { - "count": 3, - "name": "Unearth" + "count": 1, + "name": "Commercial District" }, { - "count": 1, - "name": "Raucous Theater" + "count": 4, + "name": "Sowing Mycospawn" }, { - "count": 1, - "name": "Thundering Falls" + "count": 3, + "name": "Devourer of Destiny" }, { - "count": 1, - "name": "Undercity Sewers" + "count": 4, + "name": "Kozilek's Command" }, { "count": 4, - "name": "Emperor of Bones" + "name": "Malevolent Rumble" }, { "count": 4, - "name": "Psychic Frog" + "name": "Ugin's Labyrinth" }, { "count": 1, - "name": "Sink into Stupor" + "name": "Sire of Seven Deaths" }, { - "count": 4, - "name": "Abhorrent Oculus" + "count": 2, + "name": "Ugin, Eye of the Storms" }, { "count": 2, - "name": "Quantum Riddler" + "name": "Icetill Explorer" }, { "count": 4, - "name": "Thoughtseize" + "name": "Talisman of Impulse" }, { "count": 1, - "name": "Scalding Tarn" + "name": "Ghost Quarter" }, { "count": 4, - "name": "Faithless Looting" + "name": "Utopia Sprawl" }, { "count": 1, - "name": "Blood Crypt" + "name": "Bojuka Bog" }, { - "count": 4, - "name": "Polluted Delta" + "count": 2, + "name": "Ancient Stirrings" }, { "count": 4, - "name": "Bloodstained Mire" + "name": "Eldrazi Temple" }, { - "count": 4, - "name": "Fatal Push" + "count": 1, + "name": "Cavern of Souls" }, { "count": 2, - "name": "Spell Pierce" + "name": "Stomping Ground" }, { - "count": 1, - "name": "Steam Vents" + "count": 4, + "name": "Wooded Foothills" }, { "count": 1, - "name": "Watery Grave" + "name": "Sanctum of Ugin" }, { - "count": 1, - "name": "Island" + "count": 2, + "name": "Kozilek's Return" }, { - "count": 2, - "name": "Swamp" + "count": 1, + "name": "World Breaker" }, { - "count": 4, - "name": "Archon of Cruelty" + "count": 3, + "name": "Emrakul, the Promised End" }, { - "count": 4, - "name": "Persist" + "count": 3, + "name": "Forest" }, { "count": 2, - "name": "Otherworldly Gaze" + "name": "Unholy Heat" } ], "sideboard": [ { - "count": 4, - "name": "Consign to Memory" + "count": 2, + "name": "Fade from History" }, { "count": 2, + "name": "The Stone Brain" + }, + { + "count": 3, "name": "Vexing Bauble" }, { "count": 2, - "name": "Pyroclasm" + "name": "Trinisphere" }, { - "count": 3, - "name": "Leyline of the Void" + "count": 2, + "name": "Nature's Claim" }, { "count": 2, - "name": "Damping Sphere" + "name": "Blasphemous Act" }, { "count": 2, - "name": "Mystical Dispute" + "name": "Grafdigger's Cage" } ] } diff --git a/client/public/feeds/mtggoldfish-pioneer.json b/client/public/feeds/mtggoldfish-pioneer.json index f2a1cbae24..d9d9fd37a4 100644 --- a/client/public/feeds/mtggoldfish-pioneer.json +++ b/client/public/feeds/mtggoldfish-pioneer.json @@ -5,7 +5,7 @@ "icon": "G", "format": "pioneer", "version": 1, - "updated": "2026-06-08T00:00:00Z", + "updated": "2026-06-09T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/pioneer", "decks": [ { @@ -803,288 +803,288 @@ ] }, { - "name": "Abzan Greasefang", + "name": "Mono-Red Prowess", "author": "MTGGoldfish", "colors": [ - "G", - "W", - "B" + "R" ], "tags": [ "metagame" ], "main": [ - { - "count": 3, - "name": "Bitter Triumph" - }, - { - "count": 1, - "name": "Forest" - }, { "count": 4, - "name": "Temple Garden" + "name": "Burst Lightning" }, { "count": 1, - "name": "Takenuma, Abandoned Mire" + "name": "Den of the Bugbear" }, { "count": 4, - "name": "Cache Grab" - }, - { - "count": 1, - "name": "Swamp" + "name": "Emberheart Challenger" }, { - "count": 1, - "name": "Plains" + "count": 4, + "name": "Kumano Faces Kakkazan" }, { - "count": 2, - "name": "Overlord of the Balemurk" + "count": 3, + "name": "Robber of the Rich" }, { "count": 4, - "name": "Esika's Chariot" - }, - { - "count": 1, - "name": "Lush Portico" + "name": "Monastery Swiftspear" }, { "count": 4, - "name": "Greasefang, Okiba Boss" + "name": "Monstrous Rage" }, { "count": 1, - "name": "Godless Shrine" + "name": "Mountain" }, { - "count": 1, - "name": "Emptiness" + "count": 2, + "name": "Ramunap Ruins" }, { "count": 4, - "name": "Parhelion II" + "name": "Reckless Rage" }, { - "count": 3, - "name": "Darkbore Pathway" + "count": 2, + "name": "Screaming Nemesis" }, { "count": 1, - "name": "Mana Confluence" + "name": "Sokenzan, Crucible of Defiance" }, { "count": 3, - "name": "Concealed Courtyard" - }, - { - "count": 1, - "name": "Boseiju, Who Endures" + "name": "Soul-Scar Mage" }, { "count": 3, - "name": "Blooming Marsh" + "name": "Sunspine Lynx" }, { "count": 1, - "name": "Thundering Broodwagon" + "name": "Mountain" }, { "count": 3, - "name": "Witherbloom Command" - }, - { - "count": 4, - "name": "Thoughtseize" + "name": "Mountain" }, { "count": 1, - "name": "Shadowy Backstreet" - }, - { - "count": 2, - "name": "Formidable Speaker" + "name": "Mountain" }, { - "count": 1, - "name": "Sheoldred, the Apocalypse" + "count": 9, + "name": "Mountain" }, { - "count": 1, - "name": "Skysovereign, Consul Flagship" + "count": 2, + "name": "Screaming Nemesis" }, { "count": 4, - "name": "Raffine's Informant" + "name": "Mutavault" } ], "sideboard": [ { - "count": 1, - "name": "Damping Sphere" - }, - { - "count": 1, - "name": "Ashiok, Dream Render" - }, - { - "count": 3, - "name": "Fatal Push" + "count": 2, + "name": "Flowstone Infusion" }, { "count": 2, - "name": "Duress" + "name": "Scorching Shot" }, { - "count": 1, - "name": "Knight of Autumn" + "count": 3, + "name": "Pyroclasm" }, { "count": 2, - "name": "Abrupt Decay" + "name": "Maelstrom Artisan" }, { - "count": 1, - "name": "Tear Asunder" + "count": 4, + "name": "Magebane Lizard" }, { "count": 1, - "name": "Unlicensed Hearse" + "name": "The Legend of Roku" }, { "count": 1, - "name": "Vanishing Verse" - }, - { - "count": 2, - "name": "Temporary Lockdown" + "name": "Sunspine Lynx" } ] }, { - "name": "Mono-Red Prowess", + "name": "Abzan Greasefang", "author": "MTGGoldfish", "colors": [ - "R" + "G", + "W", + "B" ], "tags": [ "metagame" ], "main": [ { - "count": 4, - "name": "Burst Lightning" + "count": 3, + "name": "Bitter Triumph" }, { "count": 1, - "name": "Den of the Bugbear" + "name": "Forest" }, { "count": 4, - "name": "Emberheart Challenger" + "name": "Temple Garden" + }, + { + "count": 1, + "name": "Takenuma, Abandoned Mire" }, { "count": 4, - "name": "Kumano Faces Kakkazan" + "name": "Cache Grab" }, { - "count": 3, - "name": "Robber of the Rich" + "count": 1, + "name": "Swamp" + }, + { + "count": 1, + "name": "Plains" + }, + { + "count": 2, + "name": "Overlord of the Balemurk" }, { "count": 4, - "name": "Monastery Swiftspear" + "name": "Esika's Chariot" + }, + { + "count": 1, + "name": "Lush Portico" }, { "count": 4, - "name": "Monstrous Rage" + "name": "Greasefang, Okiba Boss" }, { "count": 1, - "name": "Mountain" + "name": "Godless Shrine" }, { - "count": 2, - "name": "Ramunap Ruins" + "count": 1, + "name": "Emptiness" }, { "count": 4, - "name": "Reckless Rage" + "name": "Parhelion II" }, { - "count": 2, - "name": "Screaming Nemesis" + "count": 3, + "name": "Darkbore Pathway" }, { "count": 1, - "name": "Sokenzan, Crucible of Defiance" + "name": "Mana Confluence" }, { "count": 3, - "name": "Soul-Scar Mage" + "name": "Concealed Courtyard" + }, + { + "count": 1, + "name": "Boseiju, Who Endures" }, { "count": 3, - "name": "Sunspine Lynx" + "name": "Blooming Marsh" }, { "count": 1, - "name": "Mountain" + "name": "Thundering Broodwagon" }, { "count": 3, - "name": "Mountain" + "name": "Witherbloom Command" }, { - "count": 1, - "name": "Mountain" + "count": 4, + "name": "Thoughtseize" }, { - "count": 9, - "name": "Mountain" + "count": 1, + "name": "Shadowy Backstreet" }, { "count": 2, - "name": "Screaming Nemesis" + "name": "Formidable Speaker" + }, + { + "count": 1, + "name": "Sheoldred, the Apocalypse" + }, + { + "count": 1, + "name": "Skysovereign, Consul Flagship" }, { "count": 4, - "name": "Mutavault" + "name": "Raffine's Informant" } ], "sideboard": [ { - "count": 2, - "name": "Flowstone Infusion" + "count": 1, + "name": "Damping Sphere" }, { - "count": 2, - "name": "Scorching Shot" + "count": 1, + "name": "Ashiok, Dream Render" }, { "count": 3, - "name": "Pyroclasm" + "name": "Fatal Push" }, { "count": 2, - "name": "Maelstrom Artisan" + "name": "Duress" }, { - "count": 4, - "name": "Magebane Lizard" + "count": 1, + "name": "Knight of Autumn" + }, + { + "count": 2, + "name": "Abrupt Decay" }, { "count": 1, - "name": "The Legend of Roku" + "name": "Tear Asunder" }, { "count": 1, - "name": "Sunspine Lynx" + "name": "Unlicensed Hearse" + }, + { + "count": 1, + "name": "Vanishing Verse" + }, + { + "count": 2, + "name": "Temporary Lockdown" } ] }, @@ -1391,12 +1391,11 @@ ] }, { - "name": "Sultai Scapeshift", + "name": "Selesnya Angels", "author": "MTGGoldfish", "colors": [ - "B", - "G", - "U" + "W", + "G" ], "tags": [ "metagame" @@ -1404,169 +1403,97 @@ "main": [ { "count": 4, - "name": "Aftermath Analyst" - }, - { - "count": 2, - "name": "Swamp" - }, - { - "count": 1, - "name": "Emeritus of Ideation" - }, - { - "count": 1, - "name": "Takenuma, Abandoned Mire" - }, - { - "count": 4, - "name": "Lotus Field" + "name": "Bishop of Wings" }, { "count": 4, - "name": "Arboreal Grazer" + "name": "Brushland" }, { "count": 4, - "name": "Stock Up" - }, - { - "count": 1, - "name": "Boseiju, Who Endures" - }, - { - "count": 3, - "name": "Port of Karfell" - }, - { - "count": 1, - "name": "Ipnu Rivulet" + "name": "Collected Company" }, { "count": 3, - "name": "Crumbling Vestige" + "name": "Elvish Mystic" }, { "count": 4, - "name": "Spelunking" + "name": "Razorverge Thicket" }, { "count": 4, - "name": "Forest" - }, - { - "count": 1, - "name": "Thassa's Oracle" + "name": "Giada, Font of Hope" }, { "count": 4, - "name": "Scapeshift" + "name": "Hushwood Verge" }, { "count": 1, - "name": "Island" + "name": "Inspiring Overseer" }, { "count": 4, - "name": "Breeding Pool" + "name": "Kayla's Reconstruction" }, { - "count": 4, - "name": "Fabled Passage" - }, - { - "count": 4, - "name": "The Wandering Minstrel" - }, - { - "count": 4, - "name": "Hedge Maze" - }, - { - "count": 1, - "name": "Mirrorpool" - }, - { - "count": 1, - "name": "Otawara, Soaring City" + "count": 3, + "name": "Llanowar Elves" }, { "count": 1, - "name": "Emeritus of Abundance" + "name": "Plains" }, { - "count": 1, - "name": "Arid Archway" + "count": 4, + "name": "Resplendent Angel" }, { "count": 4, - "name": "Growth Spiral" + "name": "Temple Garden" }, { "count": 4, - "name": "Lumra, Bellow of the Woods" + "name": "Righteous Valkyrie" }, { "count": 3, - "name": "Castle Garenbrig" - }, - { - "count": 4, - "name": "Formidable Speaker" + "name": "Skyclave Apparition" }, { - "count": 1, - "name": "Vibrance" + "count": 2, + "name": "Plains" }, { - "count": 1, - "name": "Wistfulness" + "count": 4, + "name": "Plains" }, { - "count": 1, - "name": "Cavern of Souls" + "count": 3, + "name": "Enduring Innocence" } ], "sideboard": [ { - "count": 2, - "name": "Aether Gust" - }, - { - "count": 1, - "name": "Tranquil Frillback" - }, - { - "count": 1, - "name": "Yorion, Sky Nomad" - }, - { - "count": 1, - "name": "Wistfulness" + "count": 3, + "name": "Archon of Emeria" }, { "count": 3, - "name": "Culling Ritual" + "name": "High Noon" }, { "count": 2, - "name": "Unable to Scream" - }, - { - "count": 1, - "name": "Unable to Scream" - }, - { - "count": 1, - "name": "Bonny Pall, Clearcutter" + "name": "Get Lost" }, { - "count": 1, - "name": "Emrakul, the Promised End" + "count": 4, + "name": "Seam Rip" }, { - "count": 2, - "name": "Languish" + "count": 3, + "name": "Rest in Peace" } ] } diff --git a/client/public/feeds/mtggoldfish-standard.json b/client/public/feeds/mtggoldfish-standard.json index 78868d1267..65533b6348 100644 --- a/client/public/feeds/mtggoldfish-standard.json +++ b/client/public/feeds/mtggoldfish-standard.json @@ -5,15 +5,15 @@ "icon": "G", "format": "standard", "version": 1, - "updated": "2026-06-08T00:00:00Z", + "updated": "2026-06-09T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/standard", "decks": [ { "name": "Selesnya Landfall", "author": "MTGGoldfish", "colors": [ - "W", - "G" + "G", + "W" ], "tags": [ "metagame" @@ -24,11 +24,11 @@ "name": "Erode" }, { - "count": 1, + "count": 2, "name": "Surrak, Elusive Hunter" }, { - "count": 2, + "count": 1, "name": "Lumbering Worldwagon" }, { @@ -39,10 +39,6 @@ "count": 4, "name": "Earthbender Ascension" }, - { - "count": 2, - "name": "Temple Garden" - }, { "count": 7, "name": "Forest" @@ -51,6 +47,10 @@ "count": 4, "name": "Sazh's Chocobo" }, + { + "count": 1, + "name": "Temple Garden" + }, { "count": 2, "name": "Icetill Explorer" @@ -67,14 +67,14 @@ "count": 4, "name": "Badgermole Cub" }, - { - "count": 4, - "name": "Fabled Passage" - }, { "count": 2, "name": "Dyadrine, Synthesis Amalgam" }, + { + "count": 4, + "name": "Fabled Passage" + }, { "count": 4, "name": "Hushwood Verge" @@ -84,7 +84,7 @@ "name": "Mightform Harmonizer" }, { - "count": 3, + "count": 4, "name": "Escape Tunnel" }, { @@ -111,11 +111,11 @@ }, { "count": 1, - "name": "Restoration Magic" + "name": "Kutzil, Malamet Exemplar" }, { "count": 1, - "name": "Kutzil, Malamet Exemplar" + "name": "Ba Sing Se" }, { "count": 2, @@ -140,7 +140,6 @@ "author": "MTGGoldfish", "colors": [ "U", - "G", "R" ], "tags": [ @@ -148,21 +147,13 @@ ], "main": [ { - "count": 6, + "count": 7, "name": "Island" }, - { - "count": 1, - "name": "Willowrush Verge" - }, { "count": 4, "name": "Opt" }, - { - "count": 4, - "name": "Elusive Otter" - }, { "count": 4, "name": "Stormchaser's Talent" @@ -180,32 +171,28 @@ "name": "Colorstorm Stallion" }, { - "count": 1, - "name": "Breeding Pool" + "count": 3, + "name": "Secret Identity" }, { - "count": 4, + "count": 3, "name": "Flow State" }, { "count": 1, - "name": "Impractical Joke" + "name": "Wild Ride" }, { "count": 2, - "name": "Secret Identity" - }, - { - "count": 1, - "name": "Wild Ride" + "name": "Multiversal Passage" }, { "count": 1, - "name": "Multiversal Passage" + "name": "Into the Flood Maw" }, { - "count": 1, - "name": "Spell Pierce" + "count": 3, + "name": "Drake Hatcher" }, { "count": 4, @@ -223,6 +210,10 @@ "count": 4, "name": "Sleight of Hand" }, + { + "count": 2, + "name": "Vibrant Outburst" + }, { "count": 4, "name": "Slickshot Show-Off" @@ -238,40 +229,36 @@ "name": "Slagstorm" }, { - "count": 3, + "count": 2, "name": "Get Out" }, { - "count": 1, - "name": "Sear" - }, - { - "count": 1, - "name": "Ghost Vacuum" - }, - { - "count": 1, + "count": 2, "name": "Roaring Furnace // Steaming Sauna" }, { - "count": 2, + "count": 1, "name": "Into the Flood Maw" }, { "count": 2, - "name": "Ral, Crackling Wit" + "name": "Spell Pierce" }, { "count": 1, - "name": "Soul-Guide Lantern" + "name": "Drake Hatcher" }, { "count": 1, - "name": "Spell Pierce" + "name": "Ral, Crackling Wit" }, { - "count": 1, - "name": "Broadside Barrage" + "count": 2, + "name": "Soul-Guide Lantern" + }, + { + "count": 2, + "name": "Sunspine Lynx" } ] }, @@ -739,260 +726,260 @@ ] }, { - "name": "Izzet Lessons", + "name": "Dimir Excruciator", "author": "MTGGoldfish", "colors": [ - "U", - "R" + "B", + "U" ], "tags": [ "metagame" ], "main": [ { - "count": 6, - "name": "Island" + "count": 3, + "name": "Doomsday Excruciator" }, { "count": 4, - "name": "Gran-Gran" + "name": "Restless Reef" }, { - "count": 2, - "name": "Three Steps Ahead" + "count": 4, + "name": "Deceit" }, { "count": 3, - "name": "Consult the Star Charts" + "name": "Duress" }, { - "count": 2, - "name": "Agna Qel'a" + "count": 9, + "name": "Swamp" }, { "count": 2, - "name": "Boomerang Basics" + "name": "Winternight Stories" }, { - "count": 4, - "name": "Combustion Technique" + "count": 3, + "name": "Bitter Triumph" }, { - "count": 2, - "name": "Mountain" + "count": 4, + "name": "Superior Spider-Man" }, { "count": 2, - "name": "Iroh's Demonstration" + "name": "Cavern of Souls" }, { - "count": 4, - "name": "Firebending Lesson" + "count": 1, + "name": "Multiversal Passage" }, { - "count": 4, - "name": "Monument to Endurance" + "count": 3, + "name": "Day of Black Sun" }, { - "count": 1, - "name": "Spell Pierce" + "count": 2, + "name": "Undercity Sewers" }, { - "count": 2, - "name": "Abandon Attachments" + "count": 1, + "name": "Deadly Cover-Up" }, { "count": 4, - "name": "Accumulate Wisdom" + "name": "Requiting Hex" }, { - "count": 2, - "name": "It'll Quench Ya!" + "count": 3, + "name": "Stock Up" }, { "count": 4, - "name": "Riverpyre Verge" + "name": "Watery Grave" }, { - "count": 4, - "name": "Artist's Talent" + "count": 1, + "name": "Strategic Betrayal" }, { "count": 4, - "name": "Steam Vents" + "name": "Gloomlake Verge" }, { - "count": 4, - "name": "Spirebluff Canal" + "count": 1, + "name": "Emeritus of Ideation" + }, + { + "count": 2, + "name": "Insatiable Avarice" } ], "sideboard": [ { - "count": 2, - "name": "Stormchaser's Talent" - }, - { - "count": 2, - "name": "Negate" + "count": 1, + "name": "Strategic Betrayal" }, { "count": 1, - "name": "Boomerang Basics" + "name": "Decorum Dissertation" }, { "count": 2, - "name": "Ral, Crackling Wit" + "name": "Qarsi Revenant" }, { - "count": 2, - "name": "Quantum Riddler" + "count": 1, + "name": "Ghost Vacuum" }, { "count": 3, - "name": "Flashfreeze" + "name": "Oildeep Gearhulk" }, { "count": 1, - "name": "Spell Pierce" + "name": "Duress" }, { - "count": 1, - "name": "Spell Snare" + "count": 2, + "name": "Quantum Riddler" }, { - "count": 1, - "name": "Broadside Barrage" + "count": 2, + "name": "Flashfreeze" + }, + { + "count": 2, + "name": "Sunderflock" } ] }, { - "name": "Dimir Excruciator", + "name": "Izzet Lessons", "author": "MTGGoldfish", "colors": [ - "B", - "U" + "U", + "R" ], "tags": [ "metagame" ], "main": [ { - "count": 3, - "name": "Doomsday Excruciator" + "count": 6, + "name": "Island" }, { "count": 4, - "name": "Restless Reef" + "name": "Gran-Gran" }, { - "count": 4, - "name": "Deceit" + "count": 2, + "name": "Three Steps Ahead" }, { "count": 3, - "name": "Duress" - }, - { - "count": 9, - "name": "Swamp" + "name": "Consult the Star Charts" }, { "count": 2, - "name": "Winternight Stories" + "name": "Agna Qel'a" }, { - "count": 3, - "name": "Bitter Triumph" + "count": 2, + "name": "Boomerang Basics" }, { "count": 4, - "name": "Superior Spider-Man" + "name": "Combustion Technique" }, { "count": 2, - "name": "Cavern of Souls" + "name": "Mountain" }, { - "count": 1, - "name": "Multiversal Passage" + "count": 2, + "name": "Iroh's Demonstration" }, { - "count": 3, - "name": "Day of Black Sun" + "count": 4, + "name": "Firebending Lesson" }, { - "count": 2, - "name": "Undercity Sewers" + "count": 4, + "name": "Monument to Endurance" }, { "count": 1, - "name": "Deadly Cover-Up" + "name": "Spell Pierce" }, { - "count": 4, - "name": "Requiting Hex" + "count": 2, + "name": "Abandon Attachments" }, { - "count": 3, - "name": "Stock Up" + "count": 4, + "name": "Accumulate Wisdom" }, { - "count": 4, - "name": "Watery Grave" + "count": 2, + "name": "It'll Quench Ya!" }, { - "count": 1, - "name": "Strategic Betrayal" + "count": 4, + "name": "Riverpyre Verge" }, { "count": 4, - "name": "Gloomlake Verge" + "name": "Artist's Talent" }, { - "count": 1, - "name": "Emeritus of Ideation" + "count": 4, + "name": "Steam Vents" }, { - "count": 2, - "name": "Insatiable Avarice" + "count": 4, + "name": "Spirebluff Canal" } ], "sideboard": [ { - "count": 1, - "name": "Strategic Betrayal" + "count": 2, + "name": "Stormchaser's Talent" + }, + { + "count": 2, + "name": "Negate" }, { "count": 1, - "name": "Decorum Dissertation" + "name": "Boomerang Basics" }, { "count": 2, - "name": "Qarsi Revenant" + "name": "Ral, Crackling Wit" }, { - "count": 1, - "name": "Ghost Vacuum" + "count": 2, + "name": "Quantum Riddler" }, { "count": 3, - "name": "Oildeep Gearhulk" + "name": "Flashfreeze" }, { "count": 1, - "name": "Duress" - }, - { - "count": 2, - "name": "Quantum Riddler" + "name": "Spell Pierce" }, { - "count": 2, - "name": "Flashfreeze" + "count": 1, + "name": "Spell Snare" }, { - "count": 2, - "name": "Sunderflock" + "count": 1, + "name": "Broadside Barrage" } ] }, From 228964385e7499f449555fe1f126a1548eaf20cd Mon Sep 17 00:00:00 2001 From: phase-rs release bot Date: Mon, 25 May 2026 12:44:59 +0000 Subject: [PATCH 02/22] release: v0.1.36 --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 2 +- client/package.json | 2 +- client/src-tauri/Cargo.toml | 2 +- client/src-tauri/tauri.conf.json | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38cab1bcb7..f6de379be2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "draft-core" -version = "0.1.35" +version = "0.1.36" dependencies = [ "engine", "rand 0.9.4", @@ -532,7 +532,7 @@ dependencies = [ [[package]] name = "draft-wasm" -version = "0.1.35" +version = "0.1.36" dependencies = [ "console_error_panic_hook", "draft-core", @@ -598,7 +598,7 @@ dependencies = [ [[package]] name = "engine" -version = "0.1.35" +version = "0.1.36" dependencies = [ "assert_matches", "im", @@ -623,7 +623,7 @@ dependencies = [ [[package]] name = "engine-inventory-gen" -version = "0.1.35" +version = "0.1.36" dependencies = [ "anyhow", "proc-macro2", @@ -636,7 +636,7 @@ dependencies = [ [[package]] name = "engine-wasm" -version = "0.1.35" +version = "0.1.36" dependencies = [ "console_error_panic_hook", "engine", @@ -688,7 +688,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "feed-scraper" -version = "0.1.35" +version = "0.1.36" dependencies = [ "clap", "reqwest", @@ -1338,7 +1338,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lobby-broker" -version = "0.1.35" +version = "0.1.36" dependencies = [ "engine", "serde", @@ -1432,7 +1432,7 @@ dependencies = [ [[package]] name = "mtgish-import" -version = "0.1.35" +version = "0.1.36" dependencies = [ "anyhow", "engine", @@ -1595,7 +1595,7 @@ dependencies = [ [[package]] name = "phase-ai" -version = "0.1.35" +version = "0.1.36" dependencies = [ "engine", "rand 0.9.4", @@ -1609,7 +1609,7 @@ dependencies = [ [[package]] name = "phase-server" -version = "0.1.35" +version = "0.1.36" dependencies = [ "axum", "clap", @@ -2146,7 +2146,7 @@ dependencies = [ [[package]] name = "seat-reducer" -version = "0.1.35" +version = "0.1.36" dependencies = [ "engine", "phase-ai", @@ -2322,7 +2322,7 @@ dependencies = [ [[package]] name = "server-core" -version = "0.1.35" +version = "0.1.36" dependencies = [ "draft-core", "engine", diff --git a/Cargo.toml b/Cargo.toml index cb1b0fc582..c6286e6a2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/*"] exclude = ["client/src-tauri", "lobby-worker/broker-wasm"] [workspace.package] -version = "0.1.35" +version = "0.1.36" license = "MIT OR Apache-2.0" [workspace.dependencies] diff --git a/client/package.json b/client/package.json index dbbd2cfafd..7a591a3976 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "phase-rs-client", "private": true, - "version": "0.1.35", + "version": "0.1.36", "type": "module", "scripts": { "dev": "vite", diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index 5f8dc06d39..833fd953d0 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "phase-tauri" -version = "0.1.35" +version = "0.1.36" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index 1fc1442fcf..dddd3ac273 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/config.schema.json", "productName": "Phase", - "version": "0.1.35", + "version": "0.1.36", "identifier": "rs.phase.app", "build": { "frontendDist": "../dist", From 38442b8a83c1bfced8779a91b285b57c5929f897 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Mon, 25 May 2026 08:03:11 -0700 Subject: [PATCH 03/22] Fix draft pod resume and match handoff --- .../adapter/__tests__/draftPodAdapter.test.ts | 52 +++++++- client/src/adapter/draftPodGuestAdapter.ts | 9 ++ client/src/adapter/draftPodHostAdapter.ts | 77 +++++++++++- client/src/adapter/p2p-draft-host.ts | 109 +++++++++++++++-- client/src/components/chrome/GameMenu.tsx | 7 +- client/src/pages/DraftPodPage.tsx | 10 +- client/src/pages/GamePage.tsx | 12 ++ .../stores/__tests__/draftPodStore.test.ts | 112 ++++++++++++++++++ .../__tests__/multiplayerDraftStore.test.ts | 58 +++++++++ client/src/stores/draftPodStore.ts | 88 +++++++++----- client/src/stores/multiplayerDraftStore.ts | 108 +++++++++++++++-- 11 files changed, 571 insertions(+), 71 deletions(-) create mode 100644 client/src/stores/__tests__/draftPodStore.test.ts diff --git a/client/src/adapter/__tests__/draftPodAdapter.test.ts b/client/src/adapter/__tests__/draftPodAdapter.test.ts index 9c12fe1ba0..f33ef87a01 100644 --- a/client/src/adapter/__tests__/draftPodAdapter.test.ts +++ b/client/src/adapter/__tests__/draftPodAdapter.test.ts @@ -14,6 +14,7 @@ import type { DraftPodHostEvent } from "../draftPodHostAdapter"; import { DraftPodGuestAdapter } from "../draftPodGuestAdapter"; import type { DraftPodGuestEvent } from "../draftPodGuestAdapter"; import type { DraftPlayerView } from "../draft-adapter"; +import { loadDraftHostSession } from "../../services/draftPersistence"; // ── Mocks ────────────────────────────────────────────────────────────── @@ -41,7 +42,7 @@ const mockHostRequestPause = vi.fn(); const mockHostRequestResume = vi.fn(); const mockHostDispose = vi.fn(); const mockHostTerminateDraft = vi.fn(async () => {}); -const mockHostRestoreFromPersisted = vi.fn(async () => null); +const mockHostRestoreFromPersisted = vi.fn(async (): Promise => null); vi.mock("../p2p-draft-host", () => ({ P2PDraftHost: vi.fn().mockImplementation(() => ({ @@ -220,6 +221,40 @@ describe("DraftPodHostAdapter", () => { expect(mockHostStartDraft).toHaveBeenCalledOnce(); }); + it("restores MatchInProgress host sessions without falling back to drafting", async () => { + vi.mocked(loadDraftHostSession).mockResolvedValue({ + persistenceId: "draft-1", + roomCode: "ABCDE", + kind: "Premier", + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + seatTokens: { 0: "host" }, + seatNames: { 0: "Host" }, + kickedTokens: [], + draftStarted: true, + draftCode: "ABCDE", + draftSessionJson: "{}", + setPoolJson: "{}", + }); + const restoredView = mockView("MatchInProgress"); + mockHostRestoreFromPersisted.mockResolvedValue(restoredView); + + await adapter.initialize({ + setPoolJson: "{}", + kind: "Premier", + podSize: 8, + hostDisplayName: "Host", + tournamentFormat: "Swiss", + podPolicy: "Competitive", + persistenceId: "draft-1", + }); + + expect(adapter.status).toBe("matchInProgress"); + expect(events).toContainEqual({ type: "viewUpdated", view: restoredView }); + }); + it("delegates submitPick and returns view", async () => { await adapter.initialize({ setPoolJson: "{}", @@ -307,6 +342,21 @@ describe("DraftPodHostAdapter", () => { hostEventHandler({ type: "allDecksSubmitted" }); expect(adapter.status).toBe("pairing"); + + hostEventHandler({ + type: "bo3ChoosePlayDraw", + matchId: "match-1", + gameNumber: 2, + score: { p0_wins: 0, p1_wins: 1, draws: 0 }, + timerMs: 10_000, + }); + expect(events).toContainEqual({ + type: "bo3ChoosePlayDraw", + matchId: "match-1", + gameNumber: 2, + score: { p0_wins: 0, p1_wins: 1, draws: 0 }, + timerMs: 10_000, + }); }); it("cleans up on dispose", async () => { diff --git a/client/src/adapter/draftPodGuestAdapter.ts b/client/src/adapter/draftPodGuestAdapter.ts index c9f4bd81a9..8fa0bb2156 100644 --- a/client/src/adapter/draftPodGuestAdapter.ts +++ b/client/src/adapter/draftPodGuestAdapter.ts @@ -300,12 +300,21 @@ export class DraftPodGuestAdapter { case "Deckbuilding": if (this._status !== "deckbuilding") this.setStatus("deckbuilding"); break; + case "Pairing": + case "RoundComplete": + break; case "MatchInProgress": if (this._status !== "matchInProgress") this.setStatus("matchInProgress"); break; case "Complete": if (this._status !== "complete") this.setStatus("complete"); break; + case "Lobby": + if (this._status !== "lobby") this.setStatus("lobby"); + break; + case "Paused": + case "Abandoned": + break; } } diff --git a/client/src/adapter/draftPodHostAdapter.ts b/client/src/adapter/draftPodHostAdapter.ts index 0429eb0c5f..bf0bf89cb0 100644 --- a/client/src/adapter/draftPodHostAdapter.ts +++ b/client/src/adapter/draftPodHostAdapter.ts @@ -44,6 +44,8 @@ export type DraftPodHostEvent = | { type: "draftComplete" } | { type: "deckSubmitted"; seatIndex: number } | { type: "allDecksSubmitted" } + | { type: "draftPaused"; reason: string } + | { type: "draftResumed" } | { type: "seatJoined"; seatIndex: number; displayName: string } | { type: "seatReconnected"; seatIndex: number } | { type: "seatDisconnected"; seatIndex: number } @@ -53,6 +55,22 @@ export type DraftPodHostEvent = | { type: "matchResultReceived"; matchId: string; winnerSeat: number | null } | { type: "roundAdvanced"; newRound: number } | { type: "timerExpired" } + | { + type: "bo3SideboardPrompt"; + matchId: string; + gameNumber: number; + score: MatchScore; + loserSeat: number | null; + timerMs: number; + } + | { + type: "bo3ChoosePlayDraw"; + matchId: string; + gameNumber: number; + score: MatchScore; + timerMs: number; + } + | { type: "bo3GameStart"; matchId: string; gameNumber: number; firstPlayerSeat: number } | { type: "bo3SideboardPromptSent"; matchId: string } | { type: "bo3BothSideboardsSubmitted"; matchId: string } | { type: "bo3GameStarted"; matchId: string; gameNumber: number } @@ -60,6 +78,28 @@ export type DraftPodHostEvent = type DraftPodHostEventListener = (event: DraftPodHostEvent) => void; +function hostStatusForView(view: DraftPlayerView): DraftPodHostStatus { + switch (view.status) { + case "Lobby": + return "lobby"; + case "Drafting": + case "Paused": + return "drafting"; + case "Deckbuilding": + return "deckbuilding"; + case "Pairing": + return "pairing"; + case "MatchInProgress": + return "matchInProgress"; + case "RoundComplete": + return "roundComplete"; + case "Complete": + return "complete"; + case "Abandoned": + return "error"; + } +} + export interface DraftPodHostConfig { setPoolJson: string; kind: "Premier" | "Traditional"; @@ -174,9 +214,7 @@ export class DraftPodHostAdapter { if (persisted) { const view = await host.restoreFromPersisted(persisted); if (view) { - this.setStatus( - view.status === "Deckbuilding" ? "deckbuilding" : "drafting", - ); + this.setStatus(hostStatusForView(view)); this.emit({ type: "viewUpdated", view }); } } @@ -257,6 +295,12 @@ export class DraftPodHostAdapter { this.setStatus("pairing"); this.emit({ type: "allDecksSubmitted" }); break; + case "draftPaused": + this.emit({ type: "draftPaused", reason: event.reason }); + break; + case "draftResumed": + this.emit({ type: "draftResumed" }); + break; case "error": this.emit({ type: "error", message: event.message }); break; @@ -290,6 +334,33 @@ export class DraftPodHostAdapter { case "bo3GameStarted": this.emit({ type: "bo3GameStarted", matchId: event.matchId, gameNumber: event.gameNumber }); break; + case "bo3SideboardPrompt": + this.emit({ + type: "bo3SideboardPrompt", + matchId: event.matchId, + gameNumber: event.gameNumber, + score: event.score, + loserSeat: event.loserSeat, + timerMs: event.timerMs, + }); + break; + case "bo3ChoosePlayDraw": + this.emit({ + type: "bo3ChoosePlayDraw", + matchId: event.matchId, + gameNumber: event.gameNumber, + score: event.score, + timerMs: event.timerMs, + }); + break; + case "bo3GameStart": + this.emit({ + type: "bo3GameStart", + matchId: event.matchId, + gameNumber: event.gameNumber, + firstPlayerSeat: event.firstPlayerSeat, + }); + break; } } diff --git a/client/src/adapter/p2p-draft-host.ts b/client/src/adapter/p2p-draft-host.ts index 36663c8cef..963a070720 100644 --- a/client/src/adapter/p2p-draft-host.ts +++ b/client/src/adapter/p2p-draft-host.ts @@ -54,6 +54,8 @@ export type DraftHostEvent = | { type: "draftComplete" } | { type: "deckSubmitted"; seatIndex: number } | { type: "allDecksSubmitted" } + | { type: "draftPaused"; reason: string } + | { type: "draftResumed" } | { type: "error"; message: string } | { type: "viewUpdated"; view: DraftPlayerView } | { type: "pairingsGenerated"; round: number; pairings: PairingView[] } @@ -61,6 +63,22 @@ export type DraftHostEvent = | { type: "matchResultReceived"; matchId: string; winnerSeat: number | null } | { type: "roundAdvanced"; newRound: number } | { type: "timerExpired" } + | { + type: "bo3SideboardPrompt"; + matchId: string; + gameNumber: number; + score: MatchScore; + loserSeat: number | null; + timerMs: number; + } + | { + type: "bo3ChoosePlayDraw"; + matchId: string; + gameNumber: number; + score: MatchScore; + timerMs: number; + } + | { type: "bo3GameStart"; matchId: string; gameNumber: number; firstPlayerSeat: number } | { type: "bo3SideboardPromptSent"; matchId: string } | { type: "bo3BothSideboardsSubmitted"; matchId: string } | { type: "bo3GameStarted"; matchId: string; gameNumber: number }; @@ -146,6 +164,8 @@ export class P2PDraftHost { // Server backup upload state (D-08) private backupEndpoint: string | null = null; private picksSinceLastBackup = 0; + private persistQueue = Promise.resolve(); + private persistenceClosed = false; private static readonly BACKUP_INTERVAL_PICKS = 5; constructor( @@ -322,6 +342,9 @@ export class P2PDraftHost { view, draftCode: this.draftCode, }); + if (view.status === "MatchInProgress") { + await this.dispatchMatchLaunchesForSeat(view, seat!); + } } catch (err) { console.error("[P2PDraftHost] reconnect view failed:", err); } @@ -343,6 +366,7 @@ export class P2PDraftHost { if (this.disconnectedSeats.size === 0 && this.paused) { this.paused = false; this.broadcastToGuests({ type: "draft_resumed" }); + this.emit({ type: "draftResumed" }); } } @@ -648,6 +672,7 @@ export class P2PDraftHost { if (!this.paused) { this.paused = true; this.broadcastToGuests({ type: "draft_paused", reason: "Player disconnected" }); + this.emit({ type: "draftPaused", reason: "Player disconnected" }); } this.emit({ type: "seatDisconnected", seatIndex: seat }); @@ -778,9 +803,13 @@ export class P2PDraftHost { await this.dispatchMatchLaunch(pairing, view); } + const latestView = await this.adapter.getViewForSeat(0); + // Broadcast updated views await this.broadcastViews(); - this.emit({ type: "pairingsGenerated", round, pairings: view.pairings }); + this.persistSession(); + this.emit({ type: "pairingsGenerated", round, pairings: latestView.pairings }); + this.emit({ type: "viewUpdated", view: latestView }); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.emit({ type: "error", message: `Failed to generate pairings: ${message}` }); @@ -872,6 +901,16 @@ export class P2PDraftHost { }); } + private async dispatchMatchLaunchesForSeat(view: DraftPlayerView, seat: number): Promise { + for (const pairing of view.pairings) { + if (pairing.round !== view.current_round) continue; + if (pairing.status !== "Pending" && pairing.status !== "InProgress") continue; + if (pairing.seat_a !== seat && pairing.seat_b !== seat) continue; + + await this.dispatchMatchLaunch(pairing, view); + } + } + private isBotSeatFromView(view: DraftPlayerView, seat: number): boolean { return view.seats.find((s) => s.seat_index === seat)?.is_bot ?? this.isBotSeat(seat); } @@ -926,14 +965,12 @@ export class P2PDraftHost { // Broadcast updated views with new standings await this.broadcastViews(); + this.persistSession(); + this.emit({ type: "viewUpdated", view }); // Check if the reducer auto-advanced (Competitive mode) - if (view.status === "RoundComplete" || view.status === "Complete") { - const hostView = await this.adapter.getViewForSeat(0); - this.emit({ type: "viewUpdated", view: hostView }); - if (view.status === "Complete") { - void this.cleanupServerBackup(); - } + if (view.status === "Complete") { + void this.cleanupServerBackup(); } } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -964,6 +1001,7 @@ export class P2PDraftHost { try { await this.adapter.replaceSeatWithBot(seat); await this.broadcastViews(); + this.persistSession(); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.emit({ type: "error", message: `Failed to replace seat ${seat}: ${message}` }); @@ -1111,8 +1149,39 @@ export class P2PDraftHost { private sendToSeat(seat: number, msg: DraftP2PMessage): void { if (seat === 0) { // Host is seat 0 — emit event directly instead of sending over network - if (msg.type === "draft_match_start") { - this.emit({ type: "matchStart", launch: msg.launch }); + switch (msg.type) { + case "draft_match_start": + this.emit({ type: "matchStart", launch: msg.launch }); + break; + case "draft_bo3_sideboard_prompt": + this.emit({ + type: "bo3SideboardPrompt", + matchId: msg.matchId, + gameNumber: msg.gameNumber, + score: msg.score, + loserSeat: msg.loserSeat, + timerMs: msg.timerMs, + }); + break; + case "draft_bo3_play_draw_prompt": + this.emit({ + type: "bo3ChoosePlayDraw", + matchId: msg.matchId, + gameNumber: msg.gameNumber, + score: msg.score, + timerMs: msg.timerMs, + }); + break; + case "draft_bo3_game_start": + this.emit({ + type: "bo3GameStart", + matchId: msg.matchId, + gameNumber: msg.gameNumber, + firstPlayerSeat: msg.firstPlayerSeat, + }); + break; + default: + break; } return; } @@ -1152,6 +1221,7 @@ export class P2PDraftHost { this.clearActiveTimer(); this.paused = true; this.broadcastToGuests({ type: "draft_paused", reason: "Paused by host" }); + this.emit({ type: "draftPaused", reason: "Paused by host" }); } } @@ -1159,6 +1229,7 @@ export class P2PDraftHost { if (this.paused && this.disconnectedSeats.size === 0) { this.paused = false; this.broadcastToGuests({ type: "draft_resumed" }); + this.emit({ type: "draftResumed" }); // Restart timer if still in drafting phase if (this.draftStarted && this.podPolicy === "Competitive") { void (async () => { @@ -1176,12 +1247,14 @@ export class P2PDraftHost { // ── Persistence (P2P-05) ────────────────────────────────────────── private persistSession(): void { - if (!this.persistenceId) return; - void (async () => { + if (!this.persistenceId || this.persistenceClosed) return; + this.persistQueue = this.persistQueue.then(async () => { try { + if (this.persistenceClosed) return; const sessionJson = this.draftStarted ? await this.adapter.exportSession() : null; + if (this.persistenceClosed) return; const snapshot: PersistedDraftHostSession = { persistenceId: this.persistenceId!, @@ -1211,7 +1284,7 @@ export class P2PDraftHost { } catch (err) { console.warn("[P2PDraftHost] persist failed:", err); } - })(); + }); } /** @@ -1282,6 +1355,14 @@ export class P2PDraftHost { if (this.disconnectedSeats.size > 0) { this.paused = true; + this.emit({ type: "draftPaused", reason: "Waiting for players to reconnect" }); + } + + if (view.status === "MatchInProgress") { + await this.dispatchMatchLaunchesForSeat(view, 0); + } else if (view.status === "Pairing" && view.pairings.length === 0) { + await this.generatePairings(view.current_round + 1); + return this.adapter.getViewForSeat(0); } return view; @@ -1311,8 +1392,10 @@ export class P2PDraftHost { for (const session of this.guestSessions.values()) { await session.send({ type: "draft_host_left", reason: "Host left the draft" }); } + this.persistenceClosed = true; + await this.persistQueue; if (this.persistenceId) { - void clearDraftHostSession(this.persistenceId); + await clearDraftHostSession(this.persistenceId); } void this.cleanupServerBackup(); this.dispose(); diff --git a/client/src/components/chrome/GameMenu.tsx b/client/src/components/chrome/GameMenu.tsx index 2807ae8aec..78991faec9 100644 --- a/client/src/components/chrome/GameMenu.tsx +++ b/client/src/components/chrome/GameMenu.tsx @@ -44,6 +44,7 @@ export function GameMenu({ const menuRef = useRef(null); const cardDataMeta = useCardDataMeta(); const isDraft = searchParams.get("source") === "draft" && !!searchParams.get("draftId"); + const isDraftPodMatch = searchParams.get("mode") === "draft-match"; useEffect(() => { if (!open) return; @@ -151,6 +152,8 @@ export function GameMenu({ clearGame(gameId); navigate("/draft/quick?resume=1"); }); + } else if (isDraftPodMatch) { + navigate("/draft-pod"); } else { clearGame(gameId); navigate("/"); @@ -158,7 +161,7 @@ export function GameMenu({ }} /> { setOpen(false); if (isDraft) { @@ -166,6 +169,8 @@ export function GameMenu({ clearGame(gameId); navigate("/draft/quick?resume=1"); }); + } else if (isDraftPodMatch) { + navigate("/draft-pod"); } else { navigate("/"); } diff --git a/client/src/pages/DraftPodPage.tsx b/client/src/pages/DraftPodPage.tsx index c70e73eb5a..a744766b86 100644 --- a/client/src/pages/DraftPodPage.tsx +++ b/client/src/pages/DraftPodPage.tsx @@ -692,14 +692,6 @@ export function DraftPodPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - // Cleanup on unmount - useEffect(() => { - return () => { - void leave(true); - resetPod(); - }; - }, [leave, resetPod]); - useEffect(() => { if (searchParams.get("resume") !== "1") return; void resumeHostedPod(); @@ -715,7 +707,7 @@ export function DraftPodPage() { return (
- navigate("/") : undefined} /> +
{phaseContent(phase, handleLeave)} diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index 5251b13048..bb38a1837e 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -2057,6 +2057,7 @@ function GameOverScreen({ const source = searchParams.get("source"); const draftId = searchParams.get("draftId"); const isDraft = source === "draft" && !!draftId; + const isDraftPodMatch = mode === "draft-match"; const gameId = useGameStore((s) => s.gameId); const [resultRecorded, setResultRecorded] = useState(false); @@ -2175,6 +2176,17 @@ function GameOverScreen({ ? t("gamePage.gameOver.backToDraft") : t("gamePage.gameOver.continueRun")} + ) : isDraftPodMatch ? ( + ) : isOnlineMode ? ( ) : isDraftPodMatch ? ( + ); @@ -209,6 +215,23 @@ For non-overlay components (new zone display, counter indicators, status badges) - Subscribe to `useGameStore` for data - No dispatch needed if read-only +### Phase 2.5 — Internationalize User-Facing Text + +Every string the component authors must be a translation key, not a literal. Do this **as you write the component**, not as a cleanup pass. + +- [ ] **Pick the namespace by source directory.** A component's namespace is where it lives, not what it's about: `components/draft/*` → `"draft"`, `components/lobby/*` → `"multiplayer"`, in-game overlays (`components/modal/`, `board/`, `combat/`, etc.) → `"game"`. `common` is the implicit default ns (shared buttons like Cancel/Confirm may already live there — reuse before adding). + ```tsx + const { t } = useTranslation("game"); // opt into a ns; common is always available + ``` +- [ ] **Add the key to `client/src/i18n/locales/en/.json` first.** English is the typing oracle — referencing a key that doesn't exist in `en/` fails type-check. Other locales fall back to English automatically; you do **not** edit `es/fr/de/it/pt` (machine-translated separately). + - Key shape: nested dot paths, `camelCase` leaves, `.` (e.g. `"yourOverlay.confirm"`, `"yourOverlay.title"`). +- [ ] **Plurals via CLDR, not string math.** Use `key_one` / `key_other` + `t(key, { count })`. Never `count === 1 ? "item" : "items"`. +- [ ] **Interpolate engine data raw into a translated template.** The template is chrome (translate it); the values are engine data (leave raw): `t("yourOverlay.summary", { counterType, count })`. +- [ ] **Leave engine/card pass-through untouched.** Card names, Oracle/reminder text, and enum strings (phase, mana type, counter type) are **not** wrapped — they are localized by the content pipeline (`hooks/useEngineCardData.ts`), not `t()`. When in doubt, apply the boundary rule: did the frontend author this sentence, or is it engine data flowing through? +- [ ] **Never call `i18n.changeLanguage` directly.** The preferences store owns the active language: `usePreferencesStore.getState().setLanguage(lng)`. + +See `client/src/i18n/README.md` for the full convention set. + ### Phase 3 — GamePage Routing - [ ] **`client/src/pages/GamePage.tsx` — conditional render** @@ -234,7 +257,7 @@ If your overlay is a card choice type, integrate into the existing `CardChoiceMo ### Phase 5 — Game Log (if applicable) - [ ] **`client/src/viewmodel/logFormatting.ts`** — Event formatting - Add a case for your `GameEvent` type to produce a human-readable log string. + Add a case for your `GameEvent` type to produce a human-readable log string. **Translate the template, interpolate engine data raw** — `t("log.yourEvent", { objectId, amount })` where the sentence is chrome and the IDs/amounts are engine data. The log type label is frontend-authored (translate it); the card/object it refers to is not. - [ ] **`client/src/components/log/LogEntry.tsx`** — Custom rendering (if needed) Most events use the default text format. Only add custom rendering for events that need icons, card references, or special formatting. @@ -288,6 +311,9 @@ If your overlay is a card choice type, integrate into the existing `CardChoiceMo | Adding client-side visibility logic | Diverges from server-filtered state, multiplayer security hole | Trust the filtered state from the adapter | | Modifying `gameStore` directly | Bypasses animation pipeline and persistence | Always go through `dispatch()` | | Not using `AnimatePresence` | Overlay appears/disappears instantly | Wrap in `AnimatePresence` with enter/exit transitions | +| Hardcoded user-facing string in JSX | Untranslatable; breaks 6-locale support | Route frontend-authored text through `t()` (Phase 2.5) | +| Wrapping card/Oracle/enum text in `t()` | Double-localizes engine data; key never resolves | Leave engine pass-through raw; only `t()` frontend-authored text | +| Hand-rolled `count === 1 ? "x" : "xs"` pluralization | Wrong for non-English CLDR rules | `key_one`/`key_other` + `t(key, { count })` | --- @@ -310,6 +336,8 @@ test -f client/src/pages/GamePage.tsx && \ test -f client/src/game/dispatch.ts && \ test -f client/src/animation/eventNormalizer.ts && \ test -f client/src/components/modal/CardChoiceModal.tsx && \ +test -f client/src/i18n/README.md && \ +test -d client/src/i18n/locales/en && \ rg -q "type WaitingFor" client/src/adapter/types.ts && \ rg -q "type GameAction" client/src/adapter/types.ts && \ rg -q "type GameEvent" client/src/adapter/types.ts && \ diff --git a/.claude/skills/add-interactive-effect/SKILL.md b/.claude/skills/add-interactive-effect/SKILL.md index 8c1a8bd193..f40bfa80b9 100644 --- a/.claude/skills/add-interactive-effect/SKILL.md +++ b/.claude/skills/add-interactive-effect/SKILL.md @@ -216,6 +216,8 @@ When the continuation is created, parent targets propagate down if the sub-abili - `CardChoiceModal` → `SearchModal` — filtered card selection from list - `NamedChoiceModal` — named choices including `CardName`, `NumberRange`, `Labeled`, and `LandType` +- [ ] **Internationalize all frontend-authored text** (titles, prompts, buttons, labels) via `t()` — `const { t } = useTranslation("game")`, with keys added to `client/src/i18n/locales/en/game.json` first. Card names and Oracle text stay raw (content pipeline). Boundary rule and conventions: `client/src/i18n/README.md`. See `$add-frontend-component` Phase 2.5 for the full checklist. + ### Phase 7 — Multiplayer State Filtering (if hidden info) - [ ] **`crates/server-core/src/filter.rs` — `filter_state_for_player()`** @@ -247,7 +249,7 @@ The `NamedChoice` system is a well-contained interactive pattern with low blast | 3 | `crates/engine/src/game/engine.rs` — `ChooseOption` handler | May need custom validation (e.g., NumberRange validates parsed u8 in range) | | 4 | `client/src/adapter/types.ts` | Already generic (`choice_type: string`, `options: string[]`) — **no change needed** | | 5 | `client/src/components/modal/NamedChoiceModal.tsx` | Add rendering branch only if the existing button grid / card-name search is insufficient | -| 6 | `client/src/components/modal/NamedChoiceModal.tsx` — `CHOICE_TYPE_LABELS` | Add user-facing label for the new type | +| 6 | `client/src/components/modal/NamedChoiceModal.tsx` — `CHOICE_TYPE_TITLE_KEYS` | Map the new ChoiceType key → an i18n leaf (e.g. `Keyword: "keyword"`). The title renders via `t(\`namedChoice.title.${leaf}\`)`, so also add `namedChoice.title.` to `client/src/i18n/locales/en/game.json`. Never hardcode the label. | | 7 | `crates/engine/src/ai_support/candidates.rs` — `NamedChoice` arm | Already generates one action per option — works for any choice type with populated options | | 8 | `crates/engine/src/parser/oracle_effect/` | Add parser patterns for the new choice text | | 9 | `crates/engine/src/game/effects/choose.rs` — tests | Add test for `compute_options()` with new variant | diff --git a/.claude/skills/review-engine-plan/SKILL.md b/.claude/skills/review-engine-plan/SKILL.md index 73534c3327..b10e0e39e8 100644 --- a/.claude/skills/review-engine-plan/SKILL.md +++ b/.claude/skills/review-engine-plan/SKILL.md @@ -28,6 +28,7 @@ Review the plan as an architectural gate. Reject the plan if any required dimens - Types belong in `types/`. - Game logic must not leak into frontend or WASM bridge. - Display formatting must not leak into the engine. + - **i18n boundary:** if the plan adds frontend UI or log text, it must route frontend-authored strings through `t()` (react-i18next, keys in `client/src/i18n/locales/en/.json`) and leave engine/card pass-through raw. Reject plans that hardcode user-facing chrome strings or that wrap card/Oracle/enum text in `t()`. See `client/src/i18n/README.md`. 5. **Idiomatic Rust** - Prefer typed enums such as `ControllerRef`, `Comparator`, and `Option` over bool fields. diff --git a/.claude/skills/review-impl/SKILL.md b/.claude/skills/review-impl/SKILL.md index f6a5c79f11..de99d47779 100644 --- a/.claude/skills/review-impl/SKILL.md +++ b/.claude/skills/review-impl/SKILL.md @@ -50,6 +50,7 @@ Skip checks CI already enforces: - The frontend renders engine-provided state; it must not infer game rules or hidden data. - Check React effect dependencies, unmount cleanup, touch equivalents, mobile scroll containment, and empty/loading/error states. - Type-check passing is not proof of feature correctness; say when browser verification was not performed. +- **i18n:** Flag frontend-authored user-facing text (titles, labels, buttons, tooltips, placeholders, log templates) hardcoded in JSX instead of routed through `t()`. Conversely, flag engine/card pass-through (card names, Oracle text, interpolated enum strings) that was wrongly wrapped in `t()` — it belongs to the content pipeline, not chrome. Boundary rule: a string gets `t()` iff the frontend authored it (`client/src/i18n/README.md`). Also flag hand-rolled pluralization (`count === 1 ? …`) that should use `key_one`/`key_other`, and any direct `i18n.changeLanguage` call (the preferences store owns language). ### Multiplayer / Transport diff --git a/client/src/components/chrome/HostControlTile.tsx b/client/src/components/chrome/HostControlTile.tsx index d7c736c8c0..52744bcabb 100644 --- a/client/src/components/chrome/HostControlTile.tsx +++ b/client/src/components/chrome/HostControlTile.tsx @@ -148,7 +148,7 @@ function SeatRow({ > {AI_DIFFICULTIES.map((difficulty) => ( ))} diff --git a/client/src/components/chrome/ScreenChrome.tsx b/client/src/components/chrome/ScreenChrome.tsx index 9d2a8c2637..c00e5ffb6d 100644 --- a/client/src/components/chrome/ScreenChrome.tsx +++ b/client/src/components/chrome/ScreenChrome.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; import { usePreferencesStore } from "../../stores/preferencesStore"; import { menuButtonClass } from "../menu/buttonStyles"; @@ -54,6 +55,7 @@ export function ScreenChrome({ settingsOpen, onSettingsOpenChange, }: ScreenChromeProps) { + const { t } = useTranslation(); const language = usePreferencesStore((s) => s.language); const [internalShowSettings, setInternalShowSettings] = useState(false); const isSettingsControlled = settingsOpen !== undefined; @@ -81,8 +83,8 @@ export function ScreenChrome({ whileHover={{ y: -1 }} whileTap={{ scale: 0.98 }} onClick={onBack} - aria-label="Back" - title="Back" + aria-label={t("chrome.back")} + title={t("chrome.back")} > @@ -103,8 +105,8 @@ export function ScreenChrome({ whileHover={{ y: -1 }} whileTap={{ scale: 0.98 }} onClick={() => setShowSettings(true)} - aria-label={`Language (${language.toUpperCase()}) — open settings`} - title={`Language: ${language.toUpperCase()}`} + aria-label={t("chrome.languageSettings", { lang: language.toUpperCase() })} + title={t("chrome.languageTitle", { lang: language.toUpperCase() })} > @@ -118,8 +120,8 @@ export function ScreenChrome({ whileHover={{ y: -1 }} whileTap={{ scale: 0.98 }} onClick={() => setShowSettings(true)} - aria-label="Settings" - title="Settings" + aria-label={t("chrome.settings")} + title={t("chrome.settings")} > diff --git a/client/src/components/draft/SetSelector.tsx b/client/src/components/draft/SetSelector.tsx index aac497192a..b8b7c55f3e 100644 --- a/client/src/components/draft/SetSelector.tsx +++ b/client/src/components/draft/SetSelector.tsx @@ -71,7 +71,7 @@ export function SetSelector({ onStartDraft }: SetSelectorProps) { setSets(entries); } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : "Failed to load sets"); + setError(err instanceof Error ? err.message : t("setSelector.loadFailed")); } } finally { if (!cancelled) setLoading(false); diff --git a/client/src/components/menu/AiDifficultyDropdown.tsx b/client/src/components/menu/AiDifficultyDropdown.tsx index 304e54945c..39eda43f95 100644 --- a/client/src/components/menu/AiDifficultyDropdown.tsx +++ b/client/src/components/menu/AiDifficultyDropdown.tsx @@ -1,10 +1,6 @@ import { useTranslation } from "react-i18next"; -import { - AI_DIFFICULTIES, - getAiDifficultyLabel, - type AIDifficulty, -} from "../../constants/ai"; +import { AI_DIFFICULTIES, type AIDifficulty } from "../../constants/ai"; interface AiDifficultyDropdownProps { difficulty: AIDifficulty; @@ -29,7 +25,9 @@ export function AiDifficultyDropdown({ diff --git a/client/src/components/menu/AiOpponentConfig.tsx b/client/src/components/menu/AiOpponentConfig.tsx index e731612a53..b11ec8a031 100644 --- a/client/src/components/menu/AiOpponentConfig.tsx +++ b/client/src/components/menu/AiOpponentConfig.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { GameFormat, MatchType } from "../../adapter/types"; -import { AI_DIFFICULTIES, getAiDifficultyLabel, type AIDifficulty } from "../../constants/ai"; +import { AI_DIFFICULTIES, type AIDifficulty } from "../../constants/ai"; import type { AiDeckCandidate } from "../../services/aiDeckCatalog"; import { useAiDeckCatalog } from "../../services/aiDeckCatalog"; import { @@ -260,7 +260,7 @@ function AiSeatPanel({ const summaryDeck = isRandom ? t("aiOpponent.deckRandomCount", { count: filteredDecks.length }) : (selectedCandidate?.name ?? t("aiOpponent.deckRandom")); - const summaryDifficulty = getAiDifficultyLabel(seat.difficulty); + const summaryDifficulty = t(`aiDifficulty.levels.${seat.difficulty}`); const body = (
@@ -295,7 +295,7 @@ function AiSeatPanel({ > {AI_DIFFICULTIES.map((item) => ( ))} diff --git a/client/src/components/menu/LoadGameStateModal.tsx b/client/src/components/menu/LoadGameStateModal.tsx index 9aa0a7e0ce..c9201d9025 100644 --- a/client/src/components/menu/LoadGameStateModal.tsx +++ b/client/src/components/menu/LoadGameStateModal.tsx @@ -58,7 +58,7 @@ export function LoadGameStateModal({ open, onClose, onLoaded }: LoadGameStateMod try { handleParsed(await readImportFile(file)); } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Failed to read file"); + setError(err instanceof Error ? err.message : t("loadGameState.readFailed")); } }; diff --git a/client/src/components/menu/MyDecks.tsx b/client/src/components/menu/MyDecks.tsx index dfc9bb7ccd..8a142be61a 100644 --- a/client/src/components/menu/MyDecks.tsx +++ b/client/src/components/menu/MyDecks.tsx @@ -1093,7 +1093,7 @@ export function MyDecks({ > {FORMAT_FILTERS.map(({ key, label }) => ( ))} diff --git a/client/src/components/modal/CardChoiceModal.tsx b/client/src/components/modal/CardChoiceModal.tsx index 423aee2548..049b51fde0 100644 --- a/client/src/components/modal/CardChoiceModal.tsx +++ b/client/src/components/modal/CardChoiceModal.tsx @@ -1069,7 +1069,9 @@ function OutsideGameModal({ data }: { data: OutsideGameChoice["data"] }) { const selectedCount = Math.min(selectedCounts.get(key) ?? 0, entry.count); const isSelected = selectedCount > 0; const sourceLabel = - entry.source.type === "FaceUpExile" ? "From exile" : "From sideboard"; + entry.source.type === "FaceUpExile" + ? t("outsideGame.fromExile") + : t("outsideGame.fromSideboard"); return ( ))}
diff --git a/client/src/components/ui/LanguageFlag.tsx b/client/src/components/ui/LanguageFlag.tsx index 2376f883e5..098525efd3 100644 --- a/client/src/components/ui/LanguageFlag.tsx +++ b/client/src/components/ui/LanguageFlag.tsx @@ -92,6 +92,16 @@ function FlagPT({ className }: { className?: string }) { ); } +function FlagPL({ className }: { className?: string }) { + // White over red, horizontal halves. + return ( + + ); +} + export function LanguageFlag({ lng, className }: { lng: SupportedLng; className?: string }) { // Exhaustive over SupportedLng — a new language without a flag is a compile error. switch (lng) { @@ -107,5 +117,7 @@ export function LanguageFlag({ lng, className }: { lng: SupportedLng; className? return ; case "pt": return ; + case "pl": + return ; } } diff --git a/client/src/constants/ai.ts b/client/src/constants/ai.ts index d9d9ee7119..29d4a1b6ef 100644 --- a/client/src/constants/ai.ts +++ b/client/src/constants/ai.ts @@ -1,15 +1,13 @@ +// `id` is the engine difficulty enum; display labels are translated at render +// via `t("aiDifficulty.levels.")` (menu namespace) — not stored here. export const AI_DIFFICULTIES = [ - { id: "VeryEasy", label: "Very Easy", shortLabel: "Very Easy" }, - { id: "Easy", label: "Easy", shortLabel: "Easy" }, - { id: "Medium", label: "Medium", shortLabel: "Medium" }, - { id: "Hard", label: "Hard", shortLabel: "Hard" }, - { id: "VeryHard", label: "Very Hard", shortLabel: "Very Hard" }, + { id: "VeryEasy" }, + { id: "Easy" }, + { id: "Medium" }, + { id: "Hard" }, + { id: "VeryHard" }, ] as const; export type AIDifficulty = (typeof AI_DIFFICULTIES)[number]["id"]; export const DEFAULT_AI_DIFFICULTY: AIDifficulty = "Medium"; - -export function getAiDifficultyLabel(difficulty: string): string { - return AI_DIFFICULTIES.find((item) => item.id === difficulty)?.label ?? difficulty; -} diff --git a/client/src/hooks/usePhaseInfo.ts b/client/src/hooks/usePhaseInfo.ts index 34eb6fe77a..34d571226d 100644 --- a/client/src/hooks/usePhaseInfo.ts +++ b/client/src/hooks/usePhaseInfo.ts @@ -1,3 +1,5 @@ +import { useTranslation } from "react-i18next"; + import type { Phase } from "../adapter/types.ts"; import { useGameStore } from "../stores/gameStore.ts"; import { usePlayerId } from "./usePlayerId.ts"; @@ -74,29 +76,25 @@ const COMBAT_PHASES = new Set([ "EndCombat", ]); -const NEXT_PHASE_LABELS: Partial> = { +// Maps the current phase to the next one the "advance" button moves to. The +// display name is translated via `phaseName.`; this stays an enum map so +// the label routes through i18n rather than carrying hardcoded English. +const NEXT_PHASE: Partial> = { Untap: "Upkeep", Upkeep: "Draw", - Draw: "Main Phase 1", - PreCombatMain: "Begin Combat", - BeginCombat: "Declare Attackers", - DeclareAttackers: "Declare Blockers", - DeclareBlockers: "Combat Damage", - CombatDamage: "End Combat", - EndCombat: "Main Phase 2", - PostCombatMain: "End Step", + Draw: "PreCombatMain", + PreCombatMain: "BeginCombat", + BeginCombat: "DeclareAttackers", + DeclareAttackers: "DeclareBlockers", + DeclareBlockers: "CombatDamage", + CombatDamage: "EndCombat", + EndCombat: "PostCombatMain", + PostCombatMain: "End", End: "Cleanup", }; -function getAdvanceLabel(phase: Phase, hasStackItems: boolean, isMyTurn: boolean): string { - if (hasStackItems) return "Resolve"; - if (!isMyTurn) return "Pass Priority"; - - const nextPhaseLabel = NEXT_PHASE_LABELS[phase]; - return nextPhaseLabel ? `To ${nextPhaseLabel}` : "Pass Priority"; -} - export function usePhaseInfo(): PhaseInfo { + const { t } = useTranslation("game"); const phase = useGameStore((s) => s.gameState?.phase ?? "Untap"); const stackLength = useGameStore((s) => s.gameState?.stack.length ?? 0); const activePlayer = useGameStore((s) => s.gameState?.active_player ?? 0); @@ -106,8 +104,18 @@ export function usePhaseInfo(): PhaseInfo { const displayKey = PHASE_TO_DISPLAY[phase]; const currentOrder = DISPLAY_ORDER[displayKey]; const isCombatPhase = COMBAT_PHASES.has(phase); - const advanceLabel = getAdvanceLabel(phase, stackLength > 0, isMyTurn); - const nextPhaseLabel = NEXT_PHASE_LABELS[phase] ?? null; + + const nextPhase = NEXT_PHASE[phase]; + const nextPhaseLabel = nextPhase ? t(`phaseName.${nextPhase}`) : null; + + let advanceLabel: string; + if (stackLength > 0) { + advanceLabel = t("advance.resolve"); + } else if (!isMyTurn || !nextPhaseLabel) { + advanceLabel = t("advance.passPriority"); + } else { + advanceLabel = t("advance.toPhase", { phase: nextPhaseLabel }); + } return { displayKey, diff --git a/client/src/i18n/README.md b/client/src/i18n/README.md index c89fc1f036..318be9042b 100644 --- a/client/src/i18n/README.md +++ b/client/src/i18n/README.md @@ -57,3 +57,9 @@ Namespaces: `common` (default), `menu`, `game`, `deck-builder`, `draft`, use `usePreferencesStore.getState().setLanguage(lng)`. - English (`en`) is the typing oracle: add a key to `en/.json` **before** referencing it, or it won't type-check. Other locales fall back to English. +- Encoding: catalogs are **UTF-8 with literal accented characters** (write + `"Wähle"` directly — never `\u`-escape sequences) so translations stay + human-readable in diffs. Save files as UTF-8 with no BOM. +- Every other locale must carry the **exact same keys** as `en` — no missing + translations, no orphans. `resources.test.ts` enforces both key parity and + UTF-8 encoding across all catalogs (runs in CI + Tilt `test-frontend`). diff --git a/client/src/i18n/locales/de/common.json b/client/src/i18n/locales/de/common.json index f54727bd6a..408cd90b20 100644 --- a/client/src/i18n/locales/de/common.json +++ b/client/src/i18n/locales/de/common.json @@ -198,5 +198,11 @@ }, "turnBanner": { "turn": "Zug {{number}}" + }, + "chrome": { + "back": "Zurück", + "settings": "Einstellungen", + "languageSettings": "Sprache ({{lang}}) — Einstellungen öffnen", + "languageTitle": "Sprache: {{lang}}" } } diff --git a/client/src/i18n/locales/de/draft.json b/client/src/i18n/locales/de/draft.json index 10897ab61b..3b6f819b60 100644 --- a/client/src/i18n/locales/de/draft.json +++ b/client/src/i18n/locales/de/draft.json @@ -60,7 +60,8 @@ "botDifficulty": "Bot-Schwierigkeit", "chooseSet": "Wähle ein Set", "noPools": "Keine Draft-Pools verfügbar. Führe zuerst die Draft-Datenpipeline aus.", - "setIconAlt": "{{name}} Set-Symbol" + "setIconAlt": "{{name}} Set-Symbol", + "loadFailed": "Sets konnten nicht geladen werden" }, "pack": { "confirmPick": "Pick bestätigen", diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 0e630c2558..41a754ba1c 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -8,6 +8,7 @@ "preview": { "holdCtrlBack": "Strg halten für Rückseite", "holdCtrlFront": "Strg halten für Vorderseite", + "holdCtrlFlip": "Strg halten zum Umdrehen", "altParsedAbilities": "Alt: eingelesene Fähigkeiten", "debugId": "ID: {{id}}", "engineParse": "Engine-Parse", @@ -307,7 +308,9 @@ "statHand": "Hand", "statCreatures": "Kreaturen", "statLands": "Länder", - "statOther": "Sonstige" + "statOther": "Sonstige", + "compactHud": "Kompaktes Gegner-HUD. Reduziert jeden Gegner auf eine einzelne schmale Zeile (Name + Leben); tippe auf einen Gegner, um sein gesamtes Spielfeld anzuzeigen.", + "expandHud": "Gegner-HUD erweitern. Zeigt die Handkarten und die Spielfeldaufschlüsselung jedes Gegners." }, "combat": { "attackAll": "Alle angreifen", @@ -380,7 +383,9 @@ "nounTarget": "Ziel", "confirmTap": "Tappen bestätigen ({{selected}}/{{count}})", "keepCurrentTargets": "Aktuelle Ziele behalten", - "skip": "Überspringen" + "skip": "Überspringen", + "chooseNewTarget": "Wähle das neue Ziel des Zauberspruchs", + "chooseNewTargetForSpell": "Wähle ein neues Ziel für {{spell}}" }, "log": { "title": "Spielprotokoll", @@ -1247,6 +1252,9 @@ "proliferateSubtitle": "Wähle eine beliebige Anzahl bleibender Karten und Spieler mit Marken. Jedes gewählte Ziel erhält eine weitere Marke jeder Art, die bereits dort vorhanden ist.", "chooseObjectsTitle": "Bleibende Karten wählen", "chooseObjectsSubtitle": "Wähle eine beliebige Anzahl bleibender Karten. Du zahlst für jede gewählte Karte Kosten.", + "selectAll": "Alle", + "selectNone": "Keine", + "selectMine": "Meine Seite", "confirm": "Bestätigen" }, "replacement": { @@ -1260,7 +1268,8 @@ "scopeSingle": "Wähle ein neues Ziel für den Zauberspruch", "scopeMulti": "Wähle neue Ziele für den Zauberspruch", "subtitle": "{{scope}}. Aktuell: {{current}}", - "confirm": "Bestätigen" + "confirm": "Bestätigen", + "badgeNewTarget": "Neues Ziel" }, "separatePiles": { "chooserName": "Spieler {{number}}", @@ -1321,5 +1330,27 @@ "subtitleLabel": "Wähle eine Kennzeichnung für {{name}}", "subtitleVoteRemaining": "Gib eine Stimme ab ({{count}} verbleibend)", "subtitleVote": "Gib deine Stimme ab" + }, + "advance": { + "resolve": "Auflösen", + "passPriority": "Priorität abgeben", + "toPhase": "Zu {{phase}}" + }, + "outsideGame": { + "fromExile": "Aus dem Exil", + "fromSideboard": "Aus dem Sideboard" + }, + "phaseName": { + "Upkeep": "Versorgung", + "Draw": "Ziehen", + "PreCombatMain": "Hauptphase 1", + "BeginCombat": "Kampfbeginn", + "DeclareAttackers": "Angreifer deklarieren", + "DeclareBlockers": "Blocker deklarieren", + "CombatDamage": "Kampfschaden", + "EndCombat": "Kampfende", + "PostCombatMain": "Hauptphase 2", + "End": "Endschritt", + "Cleanup": "Aufräumen" } } diff --git a/client/src/i18n/locales/de/menu.json b/client/src/i18n/locales/de/menu.json index 57fa25a17d..34f7158ddd 100644 --- a/client/src/i18n/locales/de/menu.json +++ b/client/src/i18n/locales/de/menu.json @@ -4,7 +4,14 @@ }, "aiDifficulty": { "label": "KI-Schwierigkeit", - "ariaLabel": "KI-Schwierigkeit: {{difficulty}}" + "ariaLabel": "KI-Schwierigkeit: {{difficulty}}", + "levels": { + "VeryEasy": "Sehr leicht", + "Easy": "Leicht", + "Medium": "Mittel", + "Hard": "Schwer", + "VeryHard": "Sehr schwer" + } }, "bracketFilter": { "label": "Bracket-Filter" @@ -205,7 +212,8 @@ "browseAll": "Alle durchsuchen", "loadMore": "Mehr laden", "selectedDeck": "Ausgewähltes Deck", - "chooseDeckToContinue": "Wähle ein Deck, um fortzufahren" + "chooseDeckToContinue": "Wähle ein Deck, um fortzufahren", + "filterAll": "Alle" }, "subscriptions": { "emptyTitle": "Keine Feed-Abonnements", @@ -213,5 +221,13 @@ "feedMeta_one": "· {{count}} Deck · Aktualisiert {{date}}", "feedMeta_other": "· {{count}} Decks · Aktualisiert {{date}}", "error": "Fehler: {{error}}" + }, + "myDecksPage": { + "eyebrow": "Decks", + "title": "Decks.", + "description": "Öffne eine gespeicherte Liste, importiere eine neue oder mach im Deck-Builder weiter." + }, + "loadGameState": { + "readFailed": "Datei konnte nicht gelesen werden" } } diff --git a/client/src/i18n/locales/de/settings.json b/client/src/i18n/locales/de/settings.json index 0264415295..b72ca3a4ba 100644 --- a/client/src/i18n/locales/de/settings.json +++ b/client/src/i18n/locales/de/settings.json @@ -32,6 +32,15 @@ "random": "Zufällig", "customUrl": "Eigene URL", "none": "Keiner" + }, + "cardSizeOptions": { + "small": "Klein", + "medium": "Mittel", + "large": "Groß" + }, + "logDefaultOptions": { + "open": "Offen", + "closed": "Geschlossen" } }, "visual": { @@ -45,7 +54,12 @@ "clearArtOverrides_one": "Alle Artwork-Überschreibungen aufheben ({{count}})", "clearArtOverrides_other": "Alle Artwork-Überschreibungen aufheben ({{count}})", "clearArtOverridesConfirm_one": "Alle {{count}} Artwork-Überschreibung aufheben?", - "clearArtOverridesConfirm_other": "Alle {{count}} Artwork-Überschreibungen aufheben?" + "clearArtOverridesConfirm_other": "Alle {{count}} Artwork-Überschreibungen aufheben?", + "vfxQualityOptions": { + "full": "Voll", + "reduced": "Reduziert", + "minimal": "Minimal" + } }, "audio": { "title": "Audio", diff --git a/client/src/i18n/locales/en/common.json b/client/src/i18n/locales/en/common.json index e4409db870..3e513cd883 100644 --- a/client/src/i18n/locales/en/common.json +++ b/client/src/i18n/locales/en/common.json @@ -198,5 +198,11 @@ }, "turnBanner": { "turn": "Turn {{number}}" + }, + "chrome": { + "back": "Back", + "settings": "Settings", + "languageSettings": "Language ({{lang}}) — open settings", + "languageTitle": "Language: {{lang}}" } } diff --git a/client/src/i18n/locales/en/draft.json b/client/src/i18n/locales/en/draft.json index d6f5f45bd9..0c294d23b4 100644 --- a/client/src/i18n/locales/en/draft.json +++ b/client/src/i18n/locales/en/draft.json @@ -60,7 +60,8 @@ "botDifficulty": "Bot Difficulty", "chooseSet": "Choose a Set", "noPools": "No draft pools available. Run the draft data pipeline first.", - "setIconAlt": "{{name}} set icon" + "setIconAlt": "{{name}} set icon", + "loadFailed": "Failed to load sets" }, "pack": { "confirmPick": "Confirm Pick", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 8608bf6c17..df5d5dc80d 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -8,6 +8,7 @@ "preview": { "holdCtrlBack": "Hold Ctrl for back face", "holdCtrlFront": "Hold Ctrl for front face", + "holdCtrlFlip": "Hold Ctrl to flip", "altParsedAbilities": "Alt: parsed abilities", "debugId": "ID: {{id}}", "engineParse": "Engine Parse", @@ -1251,6 +1252,9 @@ "proliferateSubtitle": "Choose any number of permanents and players with counters. Each chosen target gets one more counter of each kind already there.", "chooseObjectsTitle": "Choose Permanents", "chooseObjectsSubtitle": "Choose any number of permanents. You pay a cost for each one chosen.", + "selectAll": "All", + "selectNone": "None", + "selectMine": "My side", "confirm": "Confirm" }, "replacement": { @@ -1326,5 +1330,27 @@ "subtitleLabel": "Choose a label for {{name}}", "subtitleVoteRemaining": "Cast a vote ({{count}} remaining)", "subtitleVote": "Cast your vote" + }, + "advance": { + "resolve": "Resolve", + "passPriority": "Pass Priority", + "toPhase": "To {{phase}}" + }, + "outsideGame": { + "fromExile": "From exile", + "fromSideboard": "From sideboard" + }, + "phaseName": { + "Upkeep": "Upkeep", + "Draw": "Draw", + "PreCombatMain": "Main Phase 1", + "BeginCombat": "Begin Combat", + "DeclareAttackers": "Declare Attackers", + "DeclareBlockers": "Declare Blockers", + "CombatDamage": "Combat Damage", + "EndCombat": "End Combat", + "PostCombatMain": "Main Phase 2", + "End": "End Step", + "Cleanup": "Cleanup" } } diff --git a/client/src/i18n/locales/en/menu.json b/client/src/i18n/locales/en/menu.json index 4d8f92044d..19efcc2140 100644 --- a/client/src/i18n/locales/en/menu.json +++ b/client/src/i18n/locales/en/menu.json @@ -4,7 +4,14 @@ }, "aiDifficulty": { "label": "AI difficulty", - "ariaLabel": "AI difficulty: {{difficulty}}" + "ariaLabel": "AI difficulty: {{difficulty}}", + "levels": { + "VeryEasy": "Very Easy", + "Easy": "Easy", + "Medium": "Medium", + "Hard": "Hard", + "VeryHard": "Very Hard" + } }, "bracketFilter": { "label": "Bracket filter" @@ -205,7 +212,8 @@ "browseAll": "Browse All", "loadMore": "Load More", "selectedDeck": "Selected deck", - "chooseDeckToContinue": "Choose a deck to continue" + "chooseDeckToContinue": "Choose a deck to continue", + "filterAll": "All" }, "subscriptions": { "emptyTitle": "No feed subscriptions", @@ -213,5 +221,13 @@ "feedMeta_one": "· {{count}} deck · Updated {{date}}", "feedMeta_other": "· {{count}} decks · Updated {{date}}", "error": "Error: {{error}}" + }, + "myDecksPage": { + "eyebrow": "Decks", + "title": "Decks.", + "description": "Open a saved list, import a new one, or continue in deck builder." + }, + "loadGameState": { + "readFailed": "Failed to read file" } } diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index d6daab5b0d..e1f4b1a710 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -32,6 +32,15 @@ "random": "Random", "customUrl": "Custom URL", "none": "None" + }, + "cardSizeOptions": { + "small": "Small", + "medium": "Medium", + "large": "Large" + }, + "logDefaultOptions": { + "open": "Open", + "closed": "Closed" } }, "visual": { @@ -45,7 +54,12 @@ "clearArtOverrides_one": "Clear All Art Overrides ({{count}})", "clearArtOverrides_other": "Clear All Art Overrides ({{count}})", "clearArtOverridesConfirm_one": "Clear all {{count}} art override?", - "clearArtOverridesConfirm_other": "Clear all {{count}} art overrides?" + "clearArtOverridesConfirm_other": "Clear all {{count}} art overrides?", + "vfxQualityOptions": { + "full": "Full", + "reduced": "Reduced", + "minimal": "Minimal" + } }, "audio": { "title": "Audio", diff --git a/client/src/i18n/locales/es/common.json b/client/src/i18n/locales/es/common.json index 7a818962f7..3476212a86 100644 --- a/client/src/i18n/locales/es/common.json +++ b/client/src/i18n/locales/es/common.json @@ -198,5 +198,11 @@ }, "turnBanner": { "turn": "Turno {{number}}" + }, + "chrome": { + "back": "Atrás", + "settings": "Ajustes", + "languageSettings": "Idioma ({{lang}}) — abrir ajustes", + "languageTitle": "Idioma: {{lang}}" } } diff --git a/client/src/i18n/locales/es/draft.json b/client/src/i18n/locales/es/draft.json index 76ff2003ec..f143523970 100644 --- a/client/src/i18n/locales/es/draft.json +++ b/client/src/i18n/locales/es/draft.json @@ -60,7 +60,8 @@ "botDifficulty": "Dificultad de los bots", "chooseSet": "Elige una colección", "noPools": "No hay grupos de draft disponibles. Ejecuta primero la canalización de datos del draft.", - "setIconAlt": "Icono de la colección {{name}}" + "setIconAlt": "Icono de la colección {{name}}", + "loadFailed": "Error al cargar las colecciones" }, "pack": { "confirmPick": "Confirmar elección", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index f22ebb2b0c..3e3a937c2c 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -8,6 +8,7 @@ "preview": { "holdCtrlBack": "Mantén Ctrl para la cara trasera", "holdCtrlFront": "Mantén Ctrl para la cara frontal", + "holdCtrlFlip": "Mantén Ctrl para girar", "altParsedAbilities": "Alt: habilidades analizadas", "debugId": "ID: {{id}}", "engineParse": "Análisis del motor", @@ -307,7 +308,9 @@ "statHand": "Mano", "statCreatures": "Criaturas", "statLands": "Tierras", - "statOther": "Otros" + "statOther": "Otros", + "compactHud": "HUD de oponente compacto. Contrae cada oponente a una sola fila fina (nombre + vidas); toca un oponente para enfocar todo su campo de batalla.", + "expandHud": "Expandir HUD de oponente. Muestra la mano y el desglose del campo de batalla de cada oponente." }, "combat": { "attackAll": "Atacar a todos", @@ -380,7 +383,9 @@ "nounTarget": "objetivo", "confirmTap": "Confirmar giro ({{selected}}/{{count}})", "keepCurrentTargets": "Conservar objetivos actuales", - "skip": "Omitir" + "skip": "Omitir", + "chooseNewTarget": "Elige el nuevo objetivo del hechizo", + "chooseNewTargetForSpell": "Elige un nuevo objetivo para {{spell}}" }, "log": { "title": "Registro de la partida", @@ -1247,6 +1252,9 @@ "proliferateSubtitle": "Elige cualquier número de permanentes y jugadores con contadores. Cada objetivo elegido recibe un contador más de cada tipo que ya tenga.", "chooseObjectsTitle": "Elegir permanentes", "chooseObjectsSubtitle": "Elige cualquier número de permanentes. Pagas un coste por cada uno elegido.", + "selectAll": "Todos", + "selectNone": "Ninguno", + "selectMine": "Mi lado", "confirm": "Confirmar" }, "replacement": { @@ -1260,7 +1268,8 @@ "scopeSingle": "Elige un nuevo objetivo para el hechizo", "scopeMulti": "Elige nuevos objetivos para el hechizo", "subtitle": "{{scope}}. Actual: {{current}}", - "confirm": "Confirmar" + "confirm": "Confirmar", + "badgeNewTarget": "Nuevo objetivo" }, "separatePiles": { "chooserName": "Jugador {{number}}", @@ -1321,5 +1330,27 @@ "subtitleLabel": "Elige una etiqueta para {{name}}", "subtitleVoteRemaining": "Emite un voto ({{count}} restantes)", "subtitleVote": "Emite tu voto" + }, + "advance": { + "resolve": "Resolver", + "passPriority": "Pasar prioridad", + "toPhase": "A {{phase}}" + }, + "outsideGame": { + "fromExile": "Desde el exilio", + "fromSideboard": "Desde el banquillo" + }, + "phaseName": { + "Upkeep": "Mantenimiento", + "Draw": "Robar", + "PreCombatMain": "Fase principal 1", + "BeginCombat": "Inicio del combate", + "DeclareAttackers": "Declarar atacantes", + "DeclareBlockers": "Declarar bloqueadores", + "CombatDamage": "Daño de combate", + "EndCombat": "Fin del combate", + "PostCombatMain": "Fase principal 2", + "End": "Paso final", + "Cleanup": "Limpieza" } } diff --git a/client/src/i18n/locales/es/menu.json b/client/src/i18n/locales/es/menu.json index 0829d66de4..dc4f2b027e 100644 --- a/client/src/i18n/locales/es/menu.json +++ b/client/src/i18n/locales/es/menu.json @@ -4,7 +4,14 @@ }, "aiDifficulty": { "label": "Dificultad de la IA", - "ariaLabel": "Dificultad de la IA: {{difficulty}}" + "ariaLabel": "Dificultad de la IA: {{difficulty}}", + "levels": { + "VeryEasy": "Muy fácil", + "Easy": "Fácil", + "Medium": "Media", + "Hard": "Difícil", + "VeryHard": "Muy difícil" + } }, "bracketFilter": { "label": "Filtro de nivel" @@ -205,7 +212,8 @@ "browseAll": "Explorar todos", "loadMore": "Cargar más", "selectedDeck": "Mazo seleccionado", - "chooseDeckToContinue": "Elige un mazo para continuar" + "chooseDeckToContinue": "Elige un mazo para continuar", + "filterAll": "Todos" }, "subscriptions": { "emptyTitle": "Sin suscripciones a feeds", @@ -213,5 +221,13 @@ "feedMeta_one": "· {{count}} mazo · Actualizado el {{date}}", "feedMeta_other": "· {{count}} mazos · Actualizado el {{date}}", "error": "Error: {{error}}" + }, + "myDecksPage": { + "eyebrow": "Mazos", + "title": "Mazos.", + "description": "Abre una lista guardada, importa una nueva o continúa en el editor de mazos." + }, + "loadGameState": { + "readFailed": "Error al leer el archivo" } } diff --git a/client/src/i18n/locales/es/settings.json b/client/src/i18n/locales/es/settings.json index c024373f65..8afced6fd8 100644 --- a/client/src/i18n/locales/es/settings.json +++ b/client/src/i18n/locales/es/settings.json @@ -32,6 +32,15 @@ "random": "Aleatorio", "customUrl": "URL personalizada", "none": "Ninguno" + }, + "cardSizeOptions": { + "small": "Pequeño", + "medium": "Mediano", + "large": "Grande" + }, + "logDefaultOptions": { + "open": "Abierto", + "closed": "Cerrado" } }, "visual": { @@ -45,7 +54,12 @@ "clearArtOverrides_one": "Borrar todas las anulaciones de ilustración ({{count}})", "clearArtOverrides_other": "Borrar todas las anulaciones de ilustración ({{count}})", "clearArtOverridesConfirm_one": "¿Borrar la anulación de ilustración? ({{count}})", - "clearArtOverridesConfirm_other": "¿Borrar las {{count}} anulaciones de ilustración?" + "clearArtOverridesConfirm_other": "¿Borrar las {{count}} anulaciones de ilustración?", + "vfxQualityOptions": { + "full": "Completa", + "reduced": "Reducida", + "minimal": "Mínima" + } }, "audio": { "title": "Audio", diff --git a/client/src/i18n/locales/fr/common.json b/client/src/i18n/locales/fr/common.json index 22dcbe4d88..76565137e4 100644 --- a/client/src/i18n/locales/fr/common.json +++ b/client/src/i18n/locales/fr/common.json @@ -198,5 +198,11 @@ }, "turnBanner": { "turn": "Tour {{number}}" + }, + "chrome": { + "back": "Retour", + "settings": "Paramètres", + "languageSettings": "Langue ({{lang}}) — ouvrir les paramètres", + "languageTitle": "Langue : {{lang}}" } } diff --git a/client/src/i18n/locales/fr/draft.json b/client/src/i18n/locales/fr/draft.json index 417597fcfd..8063065897 100644 --- a/client/src/i18n/locales/fr/draft.json +++ b/client/src/i18n/locales/fr/draft.json @@ -60,7 +60,8 @@ "botDifficulty": "Difficulté des bots", "chooseSet": "Choisir une extension", "noPools": "Aucun pool de draft disponible. Lancez d'abord le pipeline de données de draft.", - "setIconAlt": "Icône d'extension {{name}}" + "setIconAlt": "Icône d'extension {{name}}", + "loadFailed": "Échec du chargement des éditions" }, "pack": { "confirmPick": "Confirmer le choix", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 1c09fd3e51..ef9c4c48c9 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -8,6 +8,7 @@ "preview": { "holdCtrlBack": "Maintenez Ctrl pour la face arrière", "holdCtrlFront": "Maintenez Ctrl pour la face avant", + "holdCtrlFlip": "Maintenez Ctrl pour pivoter", "altParsedAbilities": "Alt : capacités analysées", "debugId": "ID : {{id}}", "engineParse": "Analyse du moteur", @@ -307,7 +308,9 @@ "statHand": "Main", "statCreatures": "Créatures", "statLands": "Terrains", - "statOther": "Autre" + "statOther": "Autre", + "compactHud": "HUD d'adversaire compact. Réduit chaque adversaire à une seule ligne fine (nom + points de vie) ; touchez un adversaire pour afficher tout son champ de bataille.", + "expandHud": "Développer le HUD d'adversaire. Affiche la main et le détail du champ de bataille de chaque adversaire." }, "combat": { "attackAll": "Attaquer tous", @@ -380,7 +383,9 @@ "nounTarget": "cible", "confirmTap": "Confirmer l'engagement ({{selected}}/{{count}})", "keepCurrentTargets": "Conserver les cibles actuelles", - "skip": "Ignorer" + "skip": "Ignorer", + "chooseNewTarget": "Choisissez la nouvelle cible du sort", + "chooseNewTargetForSpell": "Choisissez une nouvelle cible pour {{spell}}" }, "log": { "title": "Journal de partie", @@ -1247,6 +1252,9 @@ "proliferateSubtitle": "Choisissez n'importe quel nombre de permanents et de joueurs ayant des marqueurs. Chaque cible choisie reçoit un marqueur de plus de chaque sorte déjà présente.", "chooseObjectsTitle": "Choisir des permanents", "chooseObjectsSubtitle": "Choisissez n'importe quel nombre de permanents. Vous payez un coût pour chacun choisi.", + "selectAll": "Tout", + "selectNone": "Aucun", + "selectMine": "Mon côté", "confirm": "Confirmer" }, "replacement": { @@ -1260,7 +1268,8 @@ "scopeSingle": "Choisissez une nouvelle cible pour le sort", "scopeMulti": "Choisissez de nouvelles cibles pour le sort", "subtitle": "{{scope}}. Actuelle : {{current}}", - "confirm": "Confirmer" + "confirm": "Confirmer", + "badgeNewTarget": "Nouvelle cible" }, "separatePiles": { "chooserName": "Joueur {{number}}", @@ -1321,5 +1330,27 @@ "subtitleLabel": "Choisissez une étiquette pour {{name}}", "subtitleVoteRemaining": "Votez ({{count}} restant(s))", "subtitleVote": "Exprimez votre vote" + }, + "advance": { + "resolve": "Résoudre", + "passPriority": "Passer la priorité", + "toPhase": "Vers {{phase}}" + }, + "outsideGame": { + "fromExile": "Depuis l'exil", + "fromSideboard": "Depuis la réserve" + }, + "phaseName": { + "Upkeep": "Entretien", + "Draw": "Pioche", + "PreCombatMain": "Phase principale 1", + "BeginCombat": "Début de combat", + "DeclareAttackers": "Déclaration des attaquants", + "DeclareBlockers": "Déclaration des bloqueurs", + "CombatDamage": "Blessures de combat", + "EndCombat": "Fin de combat", + "PostCombatMain": "Phase principale 2", + "End": "Étape de fin", + "Cleanup": "Nettoyage" } } diff --git a/client/src/i18n/locales/fr/menu.json b/client/src/i18n/locales/fr/menu.json index b4fff56628..9f4dc9224d 100644 --- a/client/src/i18n/locales/fr/menu.json +++ b/client/src/i18n/locales/fr/menu.json @@ -4,7 +4,14 @@ }, "aiDifficulty": { "label": "Difficulté de l'IA", - "ariaLabel": "Difficulté de l'IA : {{difficulty}}" + "ariaLabel": "Difficulté de l'IA : {{difficulty}}", + "levels": { + "VeryEasy": "Très facile", + "Easy": "Facile", + "Medium": "Moyen", + "Hard": "Difficile", + "VeryHard": "Très difficile" + } }, "bracketFilter": { "label": "Filtre par palier" @@ -205,7 +212,8 @@ "browseAll": "Tout parcourir", "loadMore": "Charger plus", "selectedDeck": "Deck sélectionné", - "chooseDeckToContinue": "Choisissez un deck pour continuer" + "chooseDeckToContinue": "Choisissez un deck pour continuer", + "filterAll": "Tous" }, "subscriptions": { "emptyTitle": "Aucun abonnement à un flux", @@ -213,5 +221,13 @@ "feedMeta_one": "· {{count}} deck · Mis à jour le {{date}}", "feedMeta_other": "· {{count}} decks · Mis à jour le {{date}}", "error": "Erreur : {{error}}" + }, + "myDecksPage": { + "eyebrow": "Decks", + "title": "Decks.", + "description": "Ouvrez une liste enregistrée, importez-en une nouvelle ou continuez dans l'éditeur de deck." + }, + "loadGameState": { + "readFailed": "Échec de la lecture du fichier" } } diff --git a/client/src/i18n/locales/fr/settings.json b/client/src/i18n/locales/fr/settings.json index b3e13fc25d..d3333d9416 100644 --- a/client/src/i18n/locales/fr/settings.json +++ b/client/src/i18n/locales/fr/settings.json @@ -32,6 +32,15 @@ "random": "Aléatoire", "customUrl": "URL personnalisée", "none": "Aucun" + }, + "cardSizeOptions": { + "small": "Petit", + "medium": "Moyen", + "large": "Grand" + }, + "logDefaultOptions": { + "open": "Ouvert", + "closed": "Fermé" } }, "visual": { @@ -45,7 +54,12 @@ "clearArtOverrides_one": "Effacer tous les remplacements d'illustration ({{count}})", "clearArtOverrides_other": "Effacer tous les remplacements d'illustration ({{count}})", "clearArtOverridesConfirm_one": "Effacer les {{count}} remplacement d'illustration ?", - "clearArtOverridesConfirm_other": "Effacer les {{count}} remplacements d'illustration ?" + "clearArtOverridesConfirm_other": "Effacer les {{count}} remplacements d'illustration ?", + "vfxQualityOptions": { + "full": "Complète", + "reduced": "Réduite", + "minimal": "Minimale" + } }, "audio": { "title": "Audio", diff --git a/client/src/i18n/locales/it/common.json b/client/src/i18n/locales/it/common.json index 76e7ebcff1..b704f18c03 100644 --- a/client/src/i18n/locales/it/common.json +++ b/client/src/i18n/locales/it/common.json @@ -198,5 +198,11 @@ }, "turnBanner": { "turn": "Turno {{number}}" + }, + "chrome": { + "back": "Indietro", + "settings": "Impostazioni", + "languageSettings": "Lingua ({{lang}}) — apri impostazioni", + "languageTitle": "Lingua: {{lang}}" } } diff --git a/client/src/i18n/locales/it/draft.json b/client/src/i18n/locales/it/draft.json index 7892a88600..4847af80f7 100644 --- a/client/src/i18n/locales/it/draft.json +++ b/client/src/i18n/locales/it/draft.json @@ -60,7 +60,8 @@ "botDifficulty": "Difficoltà bot", "chooseSet": "Scegli un set", "noPools": "Nessun pool di draft disponibile. Esegui prima la pipeline dei dati di draft.", - "setIconAlt": "Icona set {{name}}" + "setIconAlt": "Icona set {{name}}", + "loadFailed": "Impossibile caricare i set" }, "pack": { "confirmPick": "Conferma scelta", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index aae64166b7..c85334ff83 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -8,6 +8,7 @@ "preview": { "holdCtrlBack": "Tieni premuto Ctrl per il retro", "holdCtrlFront": "Tieni premuto Ctrl per il fronte", + "holdCtrlFlip": "Tieni premuto Ctrl per girare", "altParsedAbilities": "Alt: abilità analizzate", "debugId": "ID: {{id}}", "engineParse": "Analisi del motore", @@ -307,7 +308,9 @@ "statHand": "Mano", "statCreatures": "Creature", "statLands": "Terre", - "statOther": "Altro" + "statOther": "Altro", + "compactHud": "HUD avversario compatto. Riduce ogni avversario a una singola riga sottile (nome + punti vita); tocca un avversario per visualizzare l'intero campo di battaglia.", + "expandHud": "Espandi HUD avversario. Mostra la mano e il dettaglio del campo di battaglia di ogni avversario." }, "combat": { "attackAll": "Attacca tutti", @@ -380,7 +383,9 @@ "nounTarget": "bersaglio", "confirmTap": "Conferma TAP ({{selected}}/{{count}})", "keepCurrentTargets": "Mantieni i bersagli attuali", - "skip": "Salta" + "skip": "Salta", + "chooseNewTarget": "Scegli il nuovo bersaglio della magia", + "chooseNewTargetForSpell": "Scegli un nuovo bersaglio per {{spell}}" }, "log": { "title": "Registro di gioco", @@ -1247,6 +1252,9 @@ "proliferateSubtitle": "Scegli un numero qualsiasi di permanenti e giocatori con segnalini. Ogni bersaglio scelto riceve un altro segnalino di ciascun tipo già presente.", "chooseObjectsTitle": "Scegli permanenti", "chooseObjectsSubtitle": "Scegli un numero qualsiasi di permanenti. Paghi un costo per ciascuno scelto.", + "selectAll": "Tutti", + "selectNone": "Nessuno", + "selectMine": "Il mio lato", "confirm": "Conferma" }, "replacement": { @@ -1260,7 +1268,8 @@ "scopeSingle": "Scegli un nuovo bersaglio per la magia", "scopeMulti": "Scegli nuovi bersagli per la magia", "subtitle": "{{scope}}. Attuale: {{current}}", - "confirm": "Conferma" + "confirm": "Conferma", + "badgeNewTarget": "Nuovo bersaglio" }, "separatePiles": { "chooserName": "Giocatore {{number}}", @@ -1321,5 +1330,27 @@ "subtitleLabel": "Scegli un'etichetta per {{name}}", "subtitleVoteRemaining": "Esprimi un voto ({{count}} rimanenti)", "subtitleVote": "Esprimi il tuo voto" + }, + "advance": { + "resolve": "Risolvi", + "passPriority": "Passa priorità", + "toPhase": "A {{phase}}" + }, + "outsideGame": { + "fromExile": "Dall'esilio", + "fromSideboard": "Dalla sideboard" + }, + "phaseName": { + "Upkeep": "Mantenimento", + "Draw": "Pesca", + "PreCombatMain": "Fase principale 1", + "BeginCombat": "Inizio combattimento", + "DeclareAttackers": "Dichiarazione attaccanti", + "DeclareBlockers": "Dichiarazione bloccanti", + "CombatDamage": "Danno da combattimento", + "EndCombat": "Fine combattimento", + "PostCombatMain": "Fase principale 2", + "End": "Sottofase finale", + "Cleanup": "Ripulitura" } } diff --git a/client/src/i18n/locales/it/menu.json b/client/src/i18n/locales/it/menu.json index 6660e30503..04d376a89e 100644 --- a/client/src/i18n/locales/it/menu.json +++ b/client/src/i18n/locales/it/menu.json @@ -4,7 +4,14 @@ }, "aiDifficulty": { "label": "Difficoltà IA", - "ariaLabel": "Difficoltà IA: {{difficulty}}" + "ariaLabel": "Difficoltà IA: {{difficulty}}", + "levels": { + "VeryEasy": "Molto facile", + "Easy": "Facile", + "Medium": "Media", + "Hard": "Difficile", + "VeryHard": "Molto difficile" + } }, "bracketFilter": { "label": "Filtro bracket" @@ -205,7 +212,8 @@ "browseAll": "Sfoglia tutti", "loadMore": "Carica altri", "selectedDeck": "Mazzo selezionato", - "chooseDeckToContinue": "Scegli un mazzo per continuare" + "chooseDeckToContinue": "Scegli un mazzo per continuare", + "filterAll": "Tutti" }, "subscriptions": { "emptyTitle": "Nessuna iscrizione ai feed", @@ -213,5 +221,13 @@ "feedMeta_one": "· {{count}} mazzo · Aggiornato {{date}}", "feedMeta_other": "· {{count}} mazzi · Aggiornato {{date}}", "error": "Errore: {{error}}" + }, + "myDecksPage": { + "eyebrow": "Mazzi", + "title": "Mazzi.", + "description": "Apri una lista salvata, importane una nuova o continua nell'editor di mazzi." + }, + "loadGameState": { + "readFailed": "Impossibile leggere il file" } } diff --git a/client/src/i18n/locales/it/settings.json b/client/src/i18n/locales/it/settings.json index 4a506e9dc7..fcac9d3592 100644 --- a/client/src/i18n/locales/it/settings.json +++ b/client/src/i18n/locales/it/settings.json @@ -32,6 +32,15 @@ "random": "Casuale", "customUrl": "URL personalizzato", "none": "Nessuno" + }, + "cardSizeOptions": { + "small": "Piccolo", + "medium": "Medio", + "large": "Grande" + }, + "logDefaultOptions": { + "open": "Aperto", + "closed": "Chiuso" } }, "visual": { @@ -45,7 +54,12 @@ "clearArtOverrides_one": "Cancella tutte le sostituzioni di illustrazione ({{count}})", "clearArtOverrides_other": "Cancella tutte le sostituzioni di illustrazione ({{count}})", "clearArtOverridesConfirm_one": "Cancellare tutte le {{count}} sostituzione di illustrazione?", - "clearArtOverridesConfirm_other": "Cancellare tutte le {{count}} sostituzioni di illustrazione?" + "clearArtOverridesConfirm_other": "Cancellare tutte le {{count}} sostituzioni di illustrazione?", + "vfxQualityOptions": { + "full": "Completa", + "reduced": "Ridotta", + "minimal": "Minima" + } }, "audio": { "title": "Audio", diff --git a/client/src/i18n/locales/pl/common.json b/client/src/i18n/locales/pl/common.json new file mode 100644 index 0000000000..cf1479ec17 --- /dev/null +++ b/client/src/i18n/locales/pl/common.json @@ -0,0 +1,208 @@ +{ + "actions": { + "cancel": "Anuluj", + "save": "Zapisz", + "close": "Zamknij", + "closeNamed": "Zamknij {{name}}" + }, + "modal": { + "defaultEyebrow": "Narzędzie warsztatu" + }, + "pausedBanner": { + "message": "Gra wstrzymana — {{reason}}" + }, + "fullscreen": { + "enter": "Włącz pełny ekran", + "exit": "Wyłącz pełny ekran" + }, + "buildBadge": { + "downloading": "pobieranie… {{progress}}%", + "checking": "sprawdzanie…", + "updating": "aktualizowanie…", + "updatePending": "aktualizacja oczekuje po zakończeniu gry", + "cards": "karty {{age}} ({{commit}})", + "cardDataTitle": "Dane kart wygenerowane {{date}} z {{commit}}", + "checkForUpdates": "Sprawdź aktualizacje", + "updaterDebugInfo": "Informacje debugowania aktualizatora", + "updaterIssue": "Problem z aktualizatorem: {{error}}", + "updateIssue": "problem z aktualizacją", + "updated": "zaktualizowano" + }, + "gameMenu": { + "menu": "Menu gry", + "sandboxTools": "Narzędzia piaskownicy", + "sandboxToolsTitle": "Narzędzia piaskownicy — skonfiguruj dowolny stan pola bitwy (`)", + "resume": "Wznów", + "settings": "Ustawienia", + "helpShortcuts": "Pomoc i skróty", + "showAiHand": "Pokaż rękę AI", + "hideAiHand": "Ukryj rękę AI", + "concede": "Poddaj się", + "backToDraft": "Wróć do draftu", + "mainMenu": "Menu główne", + "cards": "karty {{commit}}", + "cardDataTitle": "Dane kart: {{date}}" + }, + "volume": { + "mute": "Wycisz", + "unmute": "Wyłącz wyciszenie", + "volume": "Głośność" + }, + "hostControl": { + "seatHost": "Gospodarz", + "seatPlayer": "Gracz", + "seatOpen": "Wolne", + "seatAi": "AI ({{difficulty}})", + "waiting": "Oczekiwanie…", + "seatLabel": "Miejsce {{id}}", + "addAi": "Dodaj AI", + "remove": "Usuń", + "human": "Człowiek", + "kick": "Wyrzuć", + "replaceAi": "Zastąp przez AI", + "random": "Losowo", + "connecting": "Łączenie…", + "cancelHosting": "Anuluj hostowanie", + "kickConfirm": "Wyrzucić gracza {{name}} z pokoju?", + "replaceConfirm": "Zastąpić gracza {{name}} przez AI? Spowoduje to usunięcie go z pokoju.", + "fallbackPlayerName": "Gracz {{number}}", + "seatsOccupied": "{{occupied}}/{{total}} miejsc zajętych", + "startGame": "Rozpocznij grę", + "startNow": "Rozpocznij teraz", + "fillWithAi": "Wypełnij przez AI" + }, + "grantDebug": { + "heading": "Piaskownica: uprawnienia debugowania", + "description": "Przyznaj innym graczom możliwość wykonywania akcji debugowania. Ich akcje są rejestrowane publicznie.", + "playerLabel": "Gracz {{number}}", + "revoke": "ODBIERZ", + "grant": "PRZYZNAJ" + }, + "help": { + "eyebrow": "Pomoc", + "title": "Pomoc i skróty", + "subtitle": "Cyfrowy przebieg Magic dla graczy papierowych: zatrzymania, priorytet, pasowanie i narzędzia naprawcze.", + "closeHelp": "Zamknij pomoc", + "searchPlaceholder": "Szukaj w pomocy lub skrótach", + "whatCanIDo": "Co mogę teraz zrobić?", + "recoveryTitle": "Narzędzia naprawcze", + "recoveryDescription": "Jeśli karta zachowuje się nieprawidłowo, najpierw wyeksportuj stan. Zaawansowany panel debugowania jest dostępny, gdy musisz sprawdzić lub dostosować grę.", + "copyState": "Kopiuj stan", + "exportState": "Eksportuj stan", + "openAdvancedDebug": "Otwórz zaawansowane debugowanie", + "sections": { + "Flow": "Przebieg", + "Shortcuts": "Skróty", + "Recovery": "Naprawa" + }, + "status": { + "copied": "Skopiowano stan gry do schowka.", + "copyFailed": "Nie można skopiować stanu gry.", + "exported": "Wyeksportowano {{filename}}.", + "exportFailed": "Nie można wyeksportować stanu gry." + }, + "prompt": { + "starting": "Gra się rozpoczyna lub przywraca stan.", + "gameOver": "Gra zakończona.", + "mulliganDecide": "Zdecyduj, czy zatrzymać tę rękę początkową, czy wykonać mulligan.", + "mulliganWaitDecide": "Oczekiwanie, aż inny gracz zdecyduje o swojej ręce początkowej.", + "mulliganBottom": "Wybierz karty do umieszczenia na spodzie po zatrzymaniu ręki po mulliganie.", + "mulliganWaitBottom": "Oczekiwanie, aż inny gracz zakończy swój mulligan.", + "openingHandBottom": "Wybierz kartę do umieszczenia na spodzie, zanim rozpoczną się normalne mulligany.", + "openingHandWait": "Oczekiwanie, aż inny gracz rozstrzygnie swoją rękę początkową.", + "waitOther": "Oczekiwanie na ruch innego gracza.", + "priorityStack": "Masz priorytet, gdy coś jest na stosie. Rozstrzygnięcie pasuje priorytet, aby górny element mógł się rozstrzygnąć.", + "priorityAutoPass": "Masz priorytet. Klient może automatycznie pasować spokojne okna, chyba że włączone jest zatrzymanie lub Pełna kontrola.", + "priorityActions": "Masz priorytet. Możesz użyć dostępnych kart lub spasować, aby kontynuować turę.", + "priorityPass": "Masz priorytet. Pasowanie przechodzi do następnego kroku lub gracza.", + "manaPayment": "Zapłać manę za oczekujący czar lub zdolność. Naciśnij T, aby przekręcić dostępne ziemie.", + "targetSelection": "Wybierz podświetlony legalny cel lub anuluj, jeśli komunikat na to pozwala.", + "declareAttackers": "Wybierz atakujących, a następnie potwierdź atakujących. Możesz też zaatakować bez żadnych.", + "declareBlockers": "Wybierz blokujących i przypisz ich do atakujących, a następnie potwierdź blokujących.", + "chooseXValue": "Wybierz wartość X przed kontynuowaniem czaru lub zdolności.", + "payAmountChoice": "Wybierz, ile z żądanego zasobu zapłacić.", + "default": "Gra czeka na twój wybór. Postępuj zgodnie z aktywnym komunikatem, aby kontynuować." + }, + "entries": { + "automaticPhaseSkips": { + "title": "Automatyczne pomijanie faz", + "body": "Pomijanie faz jest automatyczne. Użyj zatrzymań lub Pełnej kontroli, gdy chcesz uzyskać okna priorytetu w stylu papierowym." + }, + "phaseStops": { + "title": "Zatrzymania faz", + "body": "Zatrzymanie wstrzymuje grę przed danym krokiem, podobnie jak deklaracja, że możesz zagrać przed walką, przed dobraniem lub przed krokiem końcowym w papierze." + }, + "fullControl": { + "title": "Pełna kontrola", + "body": "Pełna kontrola zapobiega pomijaniu okien priorytetu, w tym miejsc, w których cyfrowy klient normalnie kontynuowałby grę." + }, + "resolve": { + "title": "Rozstrzygnij", + "body": "Rozstrzygnięcie oznacza, że pasujesz priorytet, aby górny czar lub zdolność na stosie mogły się rozstrzygnąć, jeśli wszyscy inni również spasują." + }, + "passToEnd": { + "title": "Pasuj do końca", + "body": "Pasowanie do końca pasuje priorytet przez całą turę, chyba że przerwie je wybór, zatrzymanie lub Pełna kontrola." + }, + "manaPayment": { + "title": "Płatność many", + "body": "Podczas płatności many możesz ręcznie przekręcać ziemie lub nacisnąć T, aby przekręcić dostępne ziemie." + }, + "combatDeclarations": { + "title": "Deklaracje walki", + "body": "Gra prosi o atakujących i blokujących tylko podczas kroków deklaracji. Wybierz stwory, a następnie potwierdź deklarację." + }, + "openHelp": { + "title": "Otwórz pomoc", + "body": "Otwórz ten arkusz pomocy." + }, + "passPriority": { + "title": "Pasuj priorytet", + "body": "Pasuj priorytet lub przejdź przez bieżący komunikat priorytetu." + }, + "undo": { + "title": "Cofnij", + "body": "Cofnij ostatnią lokalną akcję, która nie ujawniła ukrytych informacji." + }, + "cancel": { + "title": "Anuluj", + "body": "Anuluj bieżący wybór, płatność many, wybór celu lub automatyczne pasowanie, gdy jest dostępne." + }, + "advancedDebugPanel": { + "title": "Zaawansowany panel debugowania", + "body": "Otwórz zaawansowany panel debugowania. Większość graczy powinna najpierw zacząć od Narzędzi naprawczych." + }, + "reportOrExportState": { + "title": "Zgłoś lub wyeksportuj stan", + "body": "Jeśli karta zachowuje się nieprawidłowo, wyeksportuj bieżący stan gry, aby można było odtworzyć dokładną pozycję na polu bitwy." + }, + "boardRightClickMenu": { + "title": "Menu pola bitwy pod prawym przyciskiem myszy", + "body": "Na komputerze kliknij prawym przyciskiem myszy puste miejsce na polu bitwy, aby uzyskać dziennik gry, narzędzia naprawcze/debugowania i ustawienia tła." + } + }, + "flowNudge": { + "message": "Pomijanie faz jest automatyczne. Użyj zatrzymań lub Pełnej kontroli, gdy chcesz uzyskać okna priorytetu w stylu papierowym.", + "dismiss": "Odrzuć", + "learnFlow": "Poznaj przebieg" + }, + "sandboxNudge": { + "message": "Skonfiguruj dowolny stan pola bitwy za pomocą Narzędzi piaskownicy — dodawaj karty i żetony, zmieniaj życie i znaczniki, kopiuj trwałe permanenty lub przeskakuj fazy. Otwórz je w dowolnej chwili klawiszem `.", + "dismiss": "Odrzuć", + "open": "Otwórz Narzędzia piaskownicy" + } + }, + "splash": { + "ready": "Gotowe", + "loading": "Ładowanie..." + }, + "turnBanner": { + "turn": "Tura {{number}}" + }, + "chrome": { + "back": "Wstecz", + "settings": "Ustawienia", + "languageSettings": "Język ({{lang}}) — otwórz ustawienia", + "languageTitle": "Język: {{lang}}" + } +} diff --git a/client/src/i18n/locales/pl/deck-builder.json b/client/src/i18n/locales/pl/deck-builder.json new file mode 100644 index 0000000000..7026fe95ae --- /dev/null +++ b/client/src/i18n/locales/pl/deck-builder.json @@ -0,0 +1,210 @@ +{ + "search": { + "title": "Szukaj", + "subtitle": "Dodaj karty do bieżącej listy.", + "reset": "Resetuj", + "textPlaceholder": "Szukaj kart...", + "allTypes": "Wszystkie typy", + "cmcMax": "Maks. CMC:", + "sets": "Dodatki", + "addSetPlaceholder": "Dodaj kod dodatku...", + "addSet": "Dodaj", + "removeSet": "Usuń {{name}}", + "emptyHint": "Dodaj filtr, aby rozpocząć przeglądanie", + "searching": "Wyszukiwanie...", + "searchFailed": "Wyszukiwanie nie powiodło się", + "results_one": "{{count}} wynik", + "results_other": "{{count}} wyników", + "browseFormat": { + "all": "Wszystkie karty" + } + }, + "filters": { + "title": "Filtry", + "close": "Zamknij filtry", + "done": "Gotowe", + "search": "Szukaj" + }, + "deck": { + "backToDeck": "Powrót do talii", + "listView": "Widok listy", + "stackView": "Widok stosu" + }, + "tabs": { + "ariaLabel": "Powierzchnia kreatora talii", + "deck": "Talia", + "info": "Informacje" + }, + "toolbar": { + "menu": "Menu", + "deckBuilder": "Kreator talii", + "untitledDeck": "Talia bez tytułu", + "deckName": "Nazwa talii", + "format": "Format", + "bracket": "Poziom", + "saved": "Zapisano ✓", + "clone": "Klonuj", + "cloneTitle": "Zapisz kopię pod nową nazwą", + "loadDeck": "Wczytaj talię...", + "nameToSave": "Nazwij talię, aby ją zapisać" + }, + "unsaved": { + "title": "Niezapisane zmiany", + "dismiss": "Zamknij okno dialogowe", + "bodyLeaving": "Masz niezapisane zmiany w tej talii. Zapisać je przed wyjściem?", + "bodyLoading": "Masz niezapisane zmiany w tej talii. Zapisać je przed wczytaniem innej talii?", + "discard": "Odrzuć", + "saveAndContinue": "Zapisz i kontynuuj" + }, + "card": { + "moveToSideboard": "Przenieś jedną kartę {{name}} do zaplecza", + "moveToMain": "Przenieś jedną kartę {{name}} do talii głównej", + "removeOne": "Usuń jedną kartę {{name}}", + "makeCommander": "Ustaw {{name}} jako dowódcę", + "makeCommanderTitle": "Ustaw jako mojego dowódcę", + "chooseArtFor": "Wybierz grafikę dla {{name}}", + "alternateArtTap": "Dostępna alternatywna grafika — dotknij, aby wybrać", + "alternateArtRightClick": "Dostępna alternatywna grafika — kliknij prawym przyciskiem, aby wybrać", + "unsupportedExpand_one": "{{count}} nieobsługiwana mechanika; rozwiń szczegóły", + "unsupportedExpand_other": "{{count}} nieobsługiwanych mechanik; rozwiń szczegóły", + "unsupportedTitle_one": "{{count}} nieobsługiwana mechanika — kliknij, aby rozwinąć", + "unsupportedTitle_other": "{{count}} nieobsługiwanych mechanik — kliknij, aby rozwinąć" + }, + "grid": { + "allFormats": "Wszystkie", + "addCard": "Dodaj {{name}}", + "notLegal": "{{name}} - Niedozwolona w formacie {{format}}", + "notFormat": "Nie {{format}}" + }, + "deckList": { + "currentList": "Bieżąca lista", + "import": "Importuj", + "importTitle": "Importuj talię z tekstu (format MTGA lub .dck)", + "export": "Eksportuj", + "exportTitle": "Eksportuj talię", + "mainTab": "Główna ({{count}})", + "sideboardTab": "Zaplecze ({{count}})", + "mainDeckHeading_one": "Talia główna ({{count}} karta)", + "mainDeckHeading_other": "Talia główna ({{count}} kart)", + "sideboardUnlimited": "Zaplecze ({{count}})", + "sideboardLimited": "Zaplecze ({{count}}/{{max}})", + "sideboardExceeds": "Zaplecze przekracza limit {{max}} kart", + "sideboardEmptyHint": "Najedź na kartę z talii głównej i kliknij →, aby ją tu przenieść.", + "group": { + "Creatures": "Stwory", + "Spells": "Czary", + "Lands": "Ziemie" + }, + "importModalTitle": "Importuj talię", + "pastePlaceholder": "Wklej listę talii (format MTGA lub .dck)...", + "fromFile": "Z pliku", + "parse": "Przetwórz", + "exportModalTitle": "Eksportuj talię", + "saveToFile": "Zapisz do pliku", + "copied": "Skopiowano!", + "copy": "Kopiuj" + }, + "moveList": { + "empty": "Pusto" + }, + "stack": { + "deckView": "Widok talii", + "visualDeckStack": "Wizualny stos talii", + "mainBadge": "Główna {{count}}", + "sideboardBadge": "Zaplecze {{count}}", + "emptyHint": "Dodane karty pojawią się tutaj jako kaskadowy stos.", + "commanderBadge": "Dowódca", + "commanderLane": "Dowódca", + "mainDeckLane": "Talia główna", + "noCommander": "Nie wybrano dowódcy.", + "mainEmpty": "Karty talii głównej pojawią się tutaj.", + "cardCount_one": "{{count}} karta", + "cardCount_other": "{{count}} kart", + "mainSideBadge": "{{main}} główna + {{side}} zaplecze", + "groupCards_one": "{{count}} karta", + "groupCards_other": "{{count}} kart", + "group": { + "Creatures": "Stwory", + "Spells": "Czary", + "Lands": "Ziemie" + }, + "sideboardGroup": "Zaplecze", + "addOne": "Dodaj jedną kartę {{name}}", + "copyLimit": "{{name}} osiągnęła limit kopii", + "removeOne": "Usuń jedną kartę {{name}}", + "removeCommander": "Usuń {{name}} jako dowódcę" + }, + "printingPicker": { + "title": "Wybierz grafikę", + "noPrintings": "Brak alternatywnych wydań dla tej karty.", + "filterPlaceholder": "Filtruj według dodatku lub numeru kolekcjonerskiego…", + "filterAriaLabel": "Filtruj wydania", + "count_one": "{{count}} wydanie", + "count_other": "{{count}} wydań", + "countMatching_one": "{{count}} wydanie pasujące do „{{query}}”", + "countMatching_other": "{{count}} wydań pasujących do „{{query}}”", + "useDefault": "Użyj domyślnej", + "noMatches": "Żadne wydanie nie pasuje do filtra.", + "noImage": "Brak obrazu", + "showMore_one": "Pokaż więcej (pozostało {{count}})", + "showMore_other": "Pokaż więcej (pozostało {{count}})" + }, + "stats": { + "formatLegality": "Dozwolenie w formacie", + "engineCoverage": "Obsługa silnika", + "unsupportedTitle": "Nieobsługiwane:\n{{list}}" + }, + "commanderPanel": { + "heading": "Dowódca", + "noCommander": "Nie wybrano dowódcy", + "remove": "Usuń", + "identity": "Tożsamość:", + "setAsCommander": "Ustaw jako dowódcę:", + "cardCount": "{{count}}/{{expected}} kart", + "singletonViolations": "Naruszenia zasady singletona: {{cards}}", + "colorViolations": "Naruszenia tożsamości kolorów: {{cards}}" + }, + "manaCurve": { + "title": "Krzywa many", + "colors": "Kolory" + }, + "legality": { + "legal": "Dozwolona", + "banned": "Zakazana", + "restricted": "Ograniczona", + "notLegal": "Niedozwolona" + }, + "contextMenu": { + "chooseArt": "Wybierz grafikę…", + "noAlternates": "Brak alternatywnych wydań", + "clearOverride": "Wyczyść nadpisanie grafiki" + }, + "bracket": { + "unavailable": "Szacowanie poziomu nie jest dostępne w tej wersji.", + "addCommander": "Dodaj dowódcę, aby zobaczyć szacowany poziom.", + "estimated": "Szacowany: B{{tier}} {{label}}", + "manual": "Ręczny: B{{tier}} {{label}}", + "mismatch": " ⚠ niezgodność", + "hideBreakdown": "Ukryj szczegóły", + "showBreakdown": "Pokaż szczegóły", + "hideBreakdownButton": "▲ Ukryj szczegóły", + "showBreakdownButton": "▼ Pokaż szczegóły", + "forced": "(wymuszony B{{tier}})", + "dataVersion": "Dane: {{version}}", + "aboutBrackets": "O poziomach ↗", + "estimatedChip": "Szacowany: B{{tier}}", + "estimatedChipFull": "Szacowany poziom: B{{tier}} {{label}}" + }, + "bracketPicker": { + "ariaLabel": "Poziom talii", + "unrated": "Bez oceny" + }, + "warnings": { + "commanderCount": "Talia ma {{count}} kart (potrzeba dokładnie {{expected}})", + "singleton": "{{name}}: wiele kopii (format singleton)", + "colorIdentity": "{{name}}: poza tożsamością kolorów dowódcy", + "minimumCount": "Talia ma {{count}} kart (minimum 60)", + "maxCopies": "{{name}}: {{count}} kopii (maks. 4)", + "companionInMain": "{{name}} jest twoim towarzyszem, ale znajduje się też w talii głównej — to narusza jego warunek budowy talii. Usuń go z talii głównej, aby użyć go jako towarzysza." + } +} diff --git a/client/src/i18n/locales/pl/draft.json b/client/src/i18n/locales/pl/draft.json new file mode 100644 index 0000000000..d30897dbba --- /dev/null +++ b/client/src/i18n/locales/pl/draft.json @@ -0,0 +1,324 @@ +{ + "intro": { + "quickTitle": "Szybki draft", + "podTitle": "Draft w grupie", + "subtitle": "Oto jak to działa", + "startDrafting": "Rozpocznij draft", + "quick": { + "step1": "Otworzysz 3 boostery po 14 kart każdy", + "step2": "Wybierz jedną kartę z każdego boostera, a resztę przekaż dalej do botów", + "step3": "Boostery zmieniają kierunek w każdej rundzie — w lewo, w prawo, w lewo", + "step4": "Po wszystkich wyborach zbuduj talię z 40 kart i rozegraj mecz" + }, + "pod": { + "step1": "Draftujesz z {{count}} graczami w grupie", + "step2": "Otwórz 3 boostery po 14 kart — wybierz jedną, resztę przekaż dalej", + "step3": "Boostery zmieniają kierunek w każdej rundzie — w lewo, w prawo, w lewo", + "step4": "Po drafcie zbuduj talię z 40 kart i rozegraj mecze turniejowe" + } + }, + "steps": { + "navLabel": "Postęp draftu", + "chooseSet": "Wybierz dodatek", + "draft": "Draft", + "buildDeck": "Zbuduj talię", + "play": "Graj" + }, + "bracket": { + "title": "Drabinka", + "versus": "vs", + "quarterfinals": "Ćwierćfinały", + "semifinals": "Półfinały", + "final": "Finał" + }, + "hostControls": { + "title": "Sterowanie gospodarza", + "resumeDraft": "Wznów draft", + "pauseDraft": "Wstrzymaj draft", + "startNextRound": "Rozpocznij następną rundę", + "overrideResult": "Zmień wynik", + "versusPair": "{{a}} v {{b}}", + "kickReplace": "Wyrzuć i zastąp", + "replaceWithBot": "Zastąp gracza {{name}} botem" + }, + "manaCurve": { + "title": "Krzywa many" + }, + "pickTimer": { + "label": "Czas na wybór", + "seconds": "{{count}}s" + }, + "scoreBadge": { + "label": "Wynik: {{count}} wygranych" + }, + "seat": { + "label": "Miejsce {{number}}", + "passingLeft": "→ Przekazywanie w lewo →", + "passingRight": "← Przekazywanie w prawo ←" + }, + "setSelector": { + "botDifficulty": "Poziom trudności botów", + "chooseSet": "Wybierz dodatek", + "noPools": "Brak dostępnych pul do draftu. Najpierw uruchom pipeline danych draftu.", + "setIconAlt": "Ikona dodatku {{name}}", + "loadFailed": "Nie udało się wczytać dodatków" + }, + "pack": { + "confirmPick": "Potwierdź wybór", + "waitingNext": "Oczekiwanie na następny booster...", + "cardsInPack": "{{count}} kart w boosterze", + "picking": "Wybieranie…", + "autoPick": "Wybór automatyczny" + }, + "pool": { + "cardsDrafted": "{{count}} wybranych kart", + "empty": "Nie wybrano jeszcze żadnych kart", + "sortColor": "Kolor", + "sortType": "Typ", + "sortCmc": "CMC" + }, + "standings": { + "title": "Klasyfikacja — Runda {{round}}", + "rank": "#", + "player": "Gracz", + "record": "Bilans", + "gwp": "GWP", + "currentPairings": "Aktualne pary", + "versus": "vs" + }, + "limitedDeck": { + "poolHeading": "Pula ({{count}})", + "allAdded": "Wszystkie karty dodane do talii.", + "mainDeck": "Talia główna", + "emptyDeckHint": "Klikaj karty z puli, aby dodać je do talii.", + "addableCards": "Karty do dodania", + "autoLands": "Automatyczne ziemie", + "suggestDeck": "Zaproponuj talię", + "submitDeck": "Zatwierdź talię", + "removeCard": "Usuń {{name}}", + "addCard": "Dodaj {{name}}", + "cardCount": "/ {{min}} kart", + "spellCount_one": "{{count}} czar", + "spellCount_other": "{{count}} czarów", + "landCount_one": "{{count}} ziemia", + "landCount_other": "{{count}} ziem", + "readyToSubmit": "gotowe do zatwierdzenia", + "moreNeeded": "potrzeba jeszcze {{count}}" + }, + "landing": { + "title": "Draft", + "startNew": "Rozpocznij nowy", + "experimental": "Eksperymentalne", + "quickDraft": { + "title": "Szybki draft", + "description": "Zdraftuj 3 boostery z 7 botami, zbuduj talię z 40 kart, a potem rozegraj mecz Bo1 przeciwko jednemu z nich." + }, + "cubeDraft": { + "title": "Draft kostki", + "description": "Wklej lub wczytaj listę kostki, dostosuj zasady boosterów i talii, a potem zdraftuj szybko przeciwko botom." + }, + "podDraft": { + "title": "Draft w grupie", + "description": "Załóż lub dołącz do grupy liczącej do 8 graczy, draftujcie razem na żywo, a potem rozegrajcie turniej szwajcarski lub na wylot." + }, + "podInProgress": "Trwa draft w grupie", + "podLabel": "Grupa {{kind}}", + "seatCount": "{{count}} miejsc", + "roomLabel": "Pokój {{code}}", + "cardsPicked": "{{count}} wybranych kart", + "resume": "Wznów", + "draftInProgress": "Trwa draft", + "draftComplete": "Draft zakończony", + "resumeMatch": "Wznów mecz", + "viewResults": "Zobacz wyniki", + "setIconAlt": "Ikona {{code}}" + }, + "podPhase": { + "lobby": "Poczekalnia", + "drafting": "Draftowanie", + "deckbuilding": "Budowanie talii", + "pairing": "Dobieranie par", + "matchInProgress": "Trwa mecz", + "complete": "Zakończono" + }, + "quickPhase": { + "drafting": "Draftowanie", + "deckbuilding": "Budowanie talii", + "matchRecord": "Mecz {{number}} — {{wins}}W-{{losses}}P", + "record": "{{wins}}W-{{losses}}P", + "runComplete": "Bieg zakończony — {{wins}}W-{{losses}}P" + }, + "relativeTime": { + "justNow": "przed chwilą", + "minutes": "{{count}} min temu", + "hours": "{{count}} godz. temu", + "days": "{{count}} dni temu" + }, + "cubeSetup": { + "defaultCubeName": "Własna kostka", + "fetchError": "Nie udało się pobrać listy kostki", + "startError": "Nie udało się rozpocząć draftu kostki", + "cubeName": "Nazwa kostki", + "seats": "Miejsca", + "packs": "Boostery", + "packSize": "Rozmiar boostera", + "minDeck": "Minimalna talia", + "exportUrl": "URL eksportu CubeCobra", + "exportUrlPlaceholder": "Wklej surowy URL eksportu lub wklej listę poniżej", + "loadUrl": "Wczytaj URL", + "deckAddables": "Karty dodawane do talii", + "addablesStandardBasics": "Standardowe ziemie podstawowe", + "addablesBasicsPlusCustom": "Ziemie podstawowe plus własne", + "addablesCustomOnly": "Tylko własne", + "customAddableCards": "Własne karty do dodania", + "customAddablePlaceholder": "Jedna nazwa karty w wierszu", + "startCubeDraft": "Rozpocznij draft kostki" + }, + "formatPicker": { + "title": "Twoja talia jest gotowa", + "subtitle": "Wybierz, jak chcesz grać.", + "startMatch": "Rozpocznij mecz", + "single": { + "label": "Pojedynczy mecz", + "description": "Rozegraj jeden mecz Bo1 swoją zdraftowaną talią." + }, + "bo3": { + "label": "Do dwóch wygranych", + "description": "Rozegraj mecz Bo3 z wymianą kart bocznych między grami." + }, + "run": { + "label": "Pełny bieg", + "description": "Graj mecze Bo1, aż osiągniesz 7 wygranych lub 3 porażki." + } + }, + "run": { + "draftRun": "Bieg draftu", + "upNext": "Następny — Mecz {{number}}", + "nextMatch": "Następny mecz", + "endRun": "Zakończ bieg", + "done": "Gotowe", + "perfectRun": "Idealny bieg", + "runComplete": "Bieg zakończony", + "runOver": "Koniec biegu", + "finishedFlawless": "Zakończyłeś z bilansem {{wins}}–{{losses}}. Bezbłędnie.", + "finishedCongrats": "Zakończyłeś z bilansem {{wins}}–{{losses}}. Gratulacje!", + "finishedRecord": "Zakończyłeś z bilansem {{wins}}–{{losses}}.", + "wins": "Wygrane", + "losses": "Porażki", + "drawCount_one": "+ {{count}} remis", + "drawCount_other": "+ {{count}} remisów", + "matchLog": "Dziennik meczu", + "matchResultTitle": "Mecz {{number}}: {{result}}", + "result": { + "win": "wygrana", + "loss": "porażka", + "draw": "remis" + }, + "resultShort": { + "win": "W", + "loss": "P", + "draw": "R" + } + }, + "page": { + "cubeDraftTitle": "Draft kostki", + "quickDraftTitle": "Szybki draft", + "setDraftTab": "Draft dodatku", + "cubeTab": "Kostka" + }, + "podSetup": { + "title": "Draft w grupie", + "subtitle": "Draftuj ze znajomymi — otwieraj boostery, wybieraj karty i graj mecze turniejowe.", + "hostCardTitle": "Załóż grupę", + "hostCardDesc": "Utwórz nowy pokój draftu. Wybierasz dodatek, format i rozmiar grupy — a potem udostępnij kod pokoju znajomym. Puste miejsca można wypełnić botami.", + "joinCardTitle": "Dołącz do grupy", + "joinCardDesc": "Wpisz kod pokoju od gospodarza, aby dołączyć do istniejącego draftu. Zostaniesz posadzony na następnym wolnym miejscu i będziesz draftować razem ze wszystkimi w czasie rzeczywistym.", + "howItWorksTitle": "Jak działa draft w grupie", + "howItWorks1": "Każdy gracz otwiera 3 boostery po 14 kart — wybierz jedną, resztę przekaż dalej", + "howItWorks2": "Boostery zmieniają kierunek w każdej rundzie (w lewo → w prawo → w lewo)", + "howItWorks3": "Po drafcie każdy buduje talię z 40 kart ze swojej puli", + "howItWorks4": "Następnie rozgrywacie turniej szwajcarski lub na pojedyncze wyeliminowanie", + "back": "← Wstecz", + "hostTitle": "Załóż grupę", + "joinTitle": "Dołącz do grupy", + "displayName": "Wyświetlana nazwa", + "namePlaceholder": "Wpisz swoją nazwę...", + "draftType": "Typ draftu", + "kindPremier": "Premier", + "kindPremierDesc": "Mecze turniejowe do jednej wygranej po budowaniu talii. Szybsze rundy, bez wymiany kart bocznych między grami.", + "kindTraditional": "Tradycyjny", + "kindTraditionalDesc": "Mecze turniejowe do dwóch wygranych po budowaniu talii, z wymianą kart bocznych między grami.", + "tournamentFormat": "Format turnieju", + "tournamentSwiss": "Szwajcarski", + "tournamentSwissDesc": "Wszyscy grają przez trzy rundy, nawet po porażce.", + "tournamentElimination": "Pojedyncze wyeliminowanie", + "tournamentEliminationDesc": "Gracze odpadają po przegranym meczu, aż pozostanie jeden zwycięzca.", + "podPolicy": "Zasady grupy", + "policyCompetitive": "Wyczynowe", + "policyCompetitiveDesc": "Wybory na czas, automatyczne wybory po upływie czasu i automatyczne przechodzenie do kolejnych rund.", + "policyCasual": "Swobodne", + "policyCasualDesc": "Wybory bez limitu czasu, ręczne przechodzenie do kolejnych rund i narzędzia gospodarza do rozwiązywania problemów.", + "podSize": "Rozmiar grupy", + "podSizeDesc": "Łącznie {{count}} miejsc. Puste miejsca można wypełnić botami z poczekalni przed rozpoczęciem draftu.", + "playerCount": "{{count}} graczy", + "setSelectorHint": "Wybierz dodatek do draftu na końcu. Wybór dodatku wczytuje jego pulę kart i tworzy pokój grupy.", + "loadingPool": "Wczytywanie danych puli dodatku...", + "roomCode": "Kod pokoju", + "roomCodePlaceholder": "Wpisz kod pokoju...", + "joinPod": "Dołącz do grupy" + }, + "podPhaseView": { + "tournamentPairings": "Pary turniejowe", + "matchesInProgress": "Trwające mecze", + "yourMatch": "Twój mecz", + "versusOpponent": "vs {{name}}", + "youAreHosting": "Jesteś gospodarzem", + "connectingOpponent": "Łączenie z przeciwnikiem...", + "waitingResults": "Oczekiwanie na wyniki meczu...", + "hidePool": "Ukryj pulę", + "reviewPool": "Przejrzyj pulę", + "roundComplete": "Runda zakończona", + "waitingNextRound": "Oczekiwanie, aż gospodarz rozpocznie następną rundę...", + "nextRoundShortly": "Następna runda rozpocznie się wkrótce..." + }, + "betweenGames": { + "game": "Gra {{number}}", + "lostPreviousGame": "Przegrałeś poprzednią grę. Wybierz:", + "seconds": "{{count}}s", + "secondsRemaining": "pozostało {{count}}s", + "playFirst": "Zagraj pierwszy", + "drawFirst": "Dobierz pierwszy", + "sideboarding": "Wymiana kart bocznych", + "waitingSideboard": "Oczekiwanie, aż przeciwnik zatwierdzi karty boczne...", + "sideboardGame": "Karty boczne — Gra {{number}}", + "sideboardHint": "Wprowadź zmiany w kartach bocznych, a potem zatwierdź. Twoja pula jest dostępna poniżej.", + "submitSideboard": "Zatwierdź karty boczne", + "preparingNext": "Przygotowywanie następnej gry..." + }, + "podComplete": { + "title": "Draft zakończony", + "returnToMenu": "Powrót do menu" + }, + "podError": { + "kicked": "Zostałeś wyrzucony z grupy", + "hostLeft": "Gospodarz opuścił draft", + "connection": "Błąd połączenia" + }, + "lobby": { + "title": "Poczekalnia grupy draftu", + "draftKind": "Draft {{kind}}", + "botSeat": "Bot", + "waitingSeat": "Oczekiwanie...", + "seatNumber": "Miejsce {{number}}", + "hostBadge": "GOSPODARZ", + "kick": "Wyrzuć", + "copyRoomCodeTitle": "Kliknij, aby skopiować kod pokoju", + "roomCode": "Kod pokoju", + "clickToCopy": "Kliknij, aby skopiować", + "seatsFilled": "{{current}} / {{total}} miejsc zajętych", + "fillWithBots": "Wypełnij puste miejsca botami", + "leave": "Opuść", + "startDraft": "Rozpocznij draft", + "waitingForHost": "Oczekiwanie, aż gospodarz rozpocznie draft..." + } +} diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json new file mode 100644 index 0000000000..1e30a75813 --- /dev/null +++ b/client/src/i18n/locales/pl/game.json @@ -0,0 +1,1356 @@ +{ + "card": { + "faceDownName": "Karta zakryta", + "loading": "Wczytywanie {{name}}", + "unimplemented": "Niezaimplementowane: {{mechanics}}", + "dfc": "DFC" + }, + "preview": { + "holdCtrlBack": "Przytrzymaj Ctrl, aby zobaczyć tylną stronę", + "holdCtrlFront": "Przytrzymaj Ctrl, aby zobaczyć przednią stronę", + "holdCtrlFlip": "Przytrzymaj Ctrl, aby odwrócić", + "altParsedAbilities": "Alt: przeanalizowane zdolności", + "debugId": "ID: {{id}}", + "engineParse": "Analiza silnika", + "altKey": "Alt", + "parse": "Analiza", + "rulings": "Interpretacje", + "rulingCount_one": "{{count}} interpretacja", + "rulingCount_other": "{{count}} interpretacji", + "vanilla": "Vanilla — brak przeanalizowanych zdolności", + "unsupported": "nieobsługiwane", + "showMore": "Pokaż {{count}} więcej", + "showLess": "Pokaż mniej", + "fromSource": "(od {{source}})", + "basePT": "(bazowo {{power}}/{{toughness}})", + "damage": "Obrażenia: {{amount}}", + "ptDeltaFrom": "{{delta}} od {{source}}", + "colors": "Kolory: {{colors}}", + "colorless": "Bezbarwny" + }, + "actionButton": { + "clearAttackers": "Wyczyść atakujących", + "attackWithAll": "Atakuj wszystkimi", + "confirmAttackers": "Potwierdź atakujących ({{count}})", + "attackWithNone": "Nie atakuj nikim", + "attackWithNoneConfirm": "Naciśnij ponownie: Nie atakuj nikim", + "confirmBlockers": "Potwierdź blokujących ({{count}})", + "resetBlocks": "Zresetuj bloki", + "blockWithNone": "Nie blokuj nikim", + "blockWithNoneConfirm": "Naciśnij ponownie: Nie blokuj nikim", + "selectAttackerForBlocker": "Wybierz atakującego, przed którym ten blokujący ma bronić", + "companionToHand": "Towarzysz do ręki", + "resolve": "Rozpatrz", + "resolveTooltip": "Przekaż priorytet, aby górny element na stosie mógł zostać rozpatrzony, jeśli każdy gracz również przekaże priorytet. Skrót: Spacja.", + "resolveAll": "Rozpatrz wszystko", + "resolveAllTooltip": "Przekazuj priorytet, dopóki stos się rozpatruje. Wymagany wybór lub zatrzymanie może to przerwać.", + "waiting": "Oczekiwanie", + "priorityTooltip": "Przekaż priorytet. Jeśli stos jest pusty, spowoduje to przejście przez bieżące okno priorytetu. Skrót: Spacja.", + "pass": "Przekaż", + "passToEndTooltip": "Automatycznie przekazuj priorytet aż do kroku końcowego, chyba że przerwie to wybór, zatrzymanie lub Pełna kontrola. Skrót: Enter.", + "autoPassing": "Automatyczne przekazywanie do kroku końcowego..." + }, + "board": { + "regroupCreatures": "Pogrupuj zduplikowane grupy stworów", + "changeBackground": "Zmień tło…", + "gameLog": "Dziennik gry", + "debugLog": "Dziennik debugowania", + "waitingForGame": "Oczekiwanie na grę...", + "clickOpponent": "Kliknij przeciwnika, aby zobaczyć jego pole bitwy", + "undo": "Cofnij" + }, + "permanent": { + "expandGroup": "Rozwiń grupę {{name}}", + "chooseToken": "Wybierz token {{name}}", + "pick": "Wybierz", + "close": "zamknij", + "eligibleCount": "{{count}} dozwolonych", + "attackingCount": "atak {{count}}", + "selectedCount": "wyb {{count}}", + "pickAll": "Wszystko", + "pickNone": "Nic", + "underAttack_one": "Atakowany przez {{count}} stwora", + "underAttack_other": "Atakowany przez {{count}} stworów", + "target": "Cel", + "copy": "Kopia", + "copyTooltip": "Kopia tokenowa prawdziwej karty" + }, + "player": { + "opponent": "Przec {{seat}}", + "cardsInHand": "Karty w ręce", + "hand": "Ręka", + "creaturesAbbr": "Stw", + "landsAbbr": "Ziem", + "otherAbbr": "Inne", + "out": "POZA GRĄ", + "eliminated": "Wyeliminowany", + "phasedOut": "Przefazowany", + "you": "Ty", + "commanderDamageFrom": "Obrażenia od dowódcy od {{source}}: {{damage}}/{{threshold}}" + }, + "attackTargetPicker": { + "heading": "Wybierz cel ataku", + "attackAll": "Atakuj wszystkich", + "splitAttacks": "Rozdziel ataki", + "confirmSplitAttacks": "Potwierdź rozdzielenie ataków", + "attackWith_one": "Atakuj {{label}} {{count}} stworem", + "attackWith_other": "Atakuj {{label}} {{count}} stworami", + "allTo": "Wszyscy → {{label}}", + "creatureFallback": "Stwór #{{id}}", + "planeswalkerFallback": "Wędrowiec #{{id}}", + "you": "Ty", + "ally": "Sojusznik", + "restoreDialog": "Przywróć okno dialogowe", + "restoreDialogTitle": "Przywróć okno dialogowe celu ataku" + }, + "fullControl": { + "on": "Pełna kontrola włączona", + "off": "Pełna kontrola wyłączona", + "tooltip": "Zatrzymuj okna priorytetu zamiast pozwalać na automatyczne przekazywanie w spokojnych momentach. Skrót: F." + }, + "passButton": { + "resolve": "Rozpatrz", + "done": "Gotowe" + }, + "lifeTotal": { + "playerLabel": "G{{seat}}" + }, + "phaseStop": { + "untapLabel": "Krok odwracania", + "untapDescription": "Twoje obrócone trwałe odwracają się tutaj.", + "upkeepLabel": "Krok utrzymania", + "upkeepDescription": "Wyzwalacze utrzymania i powtarzające się koszty następują tutaj.", + "drawLabel": "Krok dobierania", + "drawDescription": "Tutaj dobierasz kartę na turę.", + "preCombatMainLabel": "Pierwsza faza główna", + "preCombatMainDescription": "Zagraj ziemie i rzuć czary przed walką.", + "beginCombatLabel": "Krok rozpoczęcia walki", + "beginCombatDescription": "Ostatnie okno priorytetu przed wybraniem atakujących.", + "declareAttackersLabel": "Krok deklaracji atakujących", + "declareAttackersDescription": "Atakujący gracz wybiera atakujących.", + "declareBlockersLabel": "Krok deklaracji blokujących", + "declareBlockersDescription": "Broniący gracze wybierają blokujących.", + "combatDamageLabel": "Krok obrażeń bojowych", + "combatDamageDescription": "Stwory przydzielają i zadają obrażenia bojowe.", + "endCombatLabel": "Krok zakończenia walki", + "endCombatDescription": "Ostatnie okno priorytetu walki po obrażeniach.", + "postCombatMainLabel": "Druga faza główna", + "postCombatMainDescription": "Zagraj ziemie lub rzuć czary po walce.", + "endLabel": "Krok końcowy", + "endDescription": "Wyzwalacze kroku końcowego i działania końca tury następują tutaj.", + "cleanupLabel": "Krok porządkowy", + "cleanupDescription": "Obrażenia znikają i sprawdzany jest maksymalny rozmiar ręki.", + "tooltipStopSet": "Zatrzymanie ustawione: kliknij, aby usunąć to zatrzymanie automatycznego przekazywania.", + "tooltipNoStop": "Brak zatrzymania: kliknij, aby wstrzymać tutaj automatyczne przekazywanie.", + "tooltipCurrentPhase": "Bieżąca faza.", + "tooltip": "Zatrzymanie fazy: {{label}}. {{description}} {{stopText}}{{activeText}}" + }, + "phaseTracker": { + "turn": "Tura {{number}}" + }, + "attachments": { + "enchantmentsOnPlayer": "Zaklęcia na graczu", + "attachedTo": "Dołączony do", + "attachedCount_one": "{{count}} dołączony trwały", + "attachedCount_other": "{{count}} dołączonych trwałych", + "unknownHost": "Nieznany", + "target": "Cel" + }, + "battlefieldPeek": { + "boardOf": "Pole bitwy {{name}}", + "noNonlandPermanents": "Brak trwałych niebędących ziemiami", + "morePermanents_one": "+{{count}} trwały więcej", + "morePermanents_other": "+{{count}} trwałych więcej", + "notTargetable": " (nie można obrać za cel)" + }, + "disconnectDialog": { + "title": "{{name}} rozłączył się", + "reconnecting": "Ponowne łączenie… pozostało {{seconds}}s", + "pauseAndWait": "Wstrzymaj i czekaj", + "continueWithout": "Kontynuuj bez nich" + }, + "kickDialog": { + "title": "Wyrzucić {{name}}?", + "body": "Przegrają grę i nie będą mogli dołączyć ponownie.", + "kick": "Wyrzuć" + }, + "enchantmentsBadge": { + "ariaLabel_one": "1 zaklęcie na tym graczu", + "ariaLabel_other": "{{count}} zaklęć na tym graczu", + "tooltip_one": "Najedź, aby podejrzeć, kliknij, aby zobaczyć", + "tooltip_other": "{{count}} zaklęć — najedź, aby podejrzeć, kliknij, aby zobaczyć" + }, + "badges": { + "monarch": "Monarcha", + "monarchTooltip": "Monarcha — dobiera dodatkową kartę na końcu tury", + "initiative": "Inicjatywa", + "initiativeTooltip": "Ma Inicjatywę — wyprawia się do Podmiasta na początku utrzymania", + "cityBlessing": "Błogosławieństwo miasta", + "cityBlessingTooltip": "Błogosławieństwo miasta — kontroluje dziesięć lub więcej trwałych (Ascend)", + "dungeonAriaLabel": "Wyprawa w {{name}}, pokój {{room}}", + "dungeonTooltip": "Wyprawa w {{name}} — pokój {{room}}", + "poisonAriaLabel_one": "{{count}} znacznik trucizny", + "poisonAriaLabel_other": "{{count}} znaczników trucizny", + "poisonTooltip": "Znaczniki trucizny: {{count}}", + "energyAriaLabel_one": "{{count}} znacznik energii", + "energyAriaLabel_other": "{{count}} znaczników energii", + "energyTooltip": "Energia: {{count}}", + "ringAriaLabel": "Pierścień cię kusi (poziom {{level}})", + "ringTooltip": "Pierścień cię kusi — poziom {{level}}", + "radAriaLabel_one": "{{count}} znacznik promieniowania", + "radAriaLabel_other": "{{count}} znaczników promieniowania", + "radTooltip": "Znaczniki promieniowania: {{count}}", + "speedAriaLabel": "Prędkość {{value}}", + "speedTooltip": "Prędkość: {{value}}", + "companion": "Towarzysz" + }, + "avatar": { + "underAttack": "{{name}} jest atakowany" + }, + "incomingAttackers": { + "summary": "⚔×{{count}} nadchodzi od {{name}}", + "clickToFocus": "kliknij, aby skupić" + }, + "manaPool": { + "onlyForSpellType": "Wydaj tylko na rzucanie czarów określonego typu", + "onlyForCreatureType": "Wydaj tylko na rzucenie czaru stwora wybranego typu", + "onlyForTypeSpellsOrAbilities": "Wydaj tylko na czary lub zdolności określonego typu", + "onlyForSpellWithKeywordKind": "Wydaj tylko na rzucanie czarów z określonym słowem kluczowym", + "onlyForSpellWithKeywordKindFromZone": "Wydaj tylko na rzucanie czarów ze słowem kluczowym z cmentarza", + "onlyForActivation": "Wydaj tylko na aktywowanie zdolności", + "onlyForXCosts": "Wydaj tylko na koszty zawierające {X}", + "convokePayment": "Płatność Convoke", + "grantsProperty": "Nadaje czarowi właściwość" + }, + "zone": { + "emblem": "Emblemat", + "emblemFallback": "Emblemat", + "commander": "Dowódca", + "commanderTitle": "Dowódca: {{name}}", + "commanderTitleTax": "Dowódca: {{name}} (Podatek: +{{tax}})", + "castCommander": "Rzuć {{name}} — kliknij dwukrotnie lub przeciągnij, aby zagrać", + "castCommanderTax": "Rzuć {{name}} (Podatek: +{{tax}}) — kliknij dwukrotnie lub przeciągnij, aby zagrać", + "tax": "Podatek: +{{tax}}", + "companion": "Towarzysz", + "companionTitle": "Towarzysz: {{name}}", + "companionTitleUsed": "Towarzysz: {{name}} (Wykorzystany)", + "companionActivate": "Zapłać {3}: Weź {{name}} do ręki", + "used": "Wykorzystany", + "exile": "Wygnanie", + "exileTitle": "Wygnanie ({{count}})", + "graveyard": "Cmentarz", + "graveyardShort": "CM", + "graveyardTitle": "Cmentarz ({{count}})", + "libraryAlt": "Biblioteka", + "libraryCount_one": "Biblioteka ({{count}} karta)", + "libraryCount_other": "Biblioteka ({{count}} kart)", + "playFromTop": "Zagraj {{name}} z wierzchu biblioteki", + "topOfLibrary": "wierzch biblioteki", + "indicatorOpponentPrefix": "Przec ", + "graveyardLower": "cmentarz", + "exileLower": "wygnanie", + "zoneTitle": "{{zone}} ({{count}})", + "noCardsIn": "Brak kart w strefie {{zone}}", + "castFromZone": "Rzuć z {{zone}}: {{name}}", + "viewCount": "Zobacz {{count}}", + "castableCount": "{{zone}} — {{count}} możliwych do rzucenia" + }, + "mana": { + "free": "Za darmo", + "chooseXTitle": "Wybierz wartość dla X", + "chooseXAria": "Wybierz wartość X", + "xEquals": "X = {{value}}", + "minMax": "min {{min}} / maks {{max}}", + "maxOnly": "maks {{max}}", + "confirmX": "Potwierdź X = {{value}}", + "payMana": "Zapłać koszt many", + "convokeHint": "Obróć stwory, aby pomóc zapłacić.", + "improviseHint": "Obróć artefakty, aby pomóc zapłacić.", + "convokeOrImproviseHint": "Obróć stwory lub artefakty, aby pomóc zapłacić.", + "lifeAmount": "2 życia", + "lifeCostSummary": "({{count}} życia)", + "paymentPending": "Płatność wciąż oczekuje. Obróć trwałe lub anuluj tę akcję.", + "poolLabel": "Pula:", + "poolEmpty": "Pusta", + "pay": "Zapłać", + "chooseAmountTitle": "Wybierz kwotę do zapłaty", + "chooseAmountAria": "Wybierz kwotę do zapłaty", + "payAmount": "Zapłać {{value}} {{resource}}", + "resourceEnergy": "energii", + "resourceMana": "many" + }, + "hand": { + "viewFullHand_one": "Zobacz całą rękę ({{count}} karta)", + "viewFullHand_other": "Zobacz całą rękę ({{count}} kart)", + "handLabel": "Ręka {{count}}", + "handTitle": "Ręka ({{count}})", + "cardBack": "Rewers karty" + }, + "opponentHud": { + "ally": "Sojusznik", + "out": "Poza grą", + "phased": "Przefazowany", + "followingActiveOpponent": "Śledzenie aktywnego przeciwnika. Skupienie przełącza się na przeciwnika, którego jest tura.", + "followActiveOpponent": "Śledź aktywnego przeciwnika. Skupienie przełączy się na przeciwnika, którego jest tura.", + "compactHud": "Kompaktowy HUD przeciwnika. Zwija każdego przeciwnika do jednego cienkiego wiersza (imię + życie); dotknij przeciwnika, aby skupić jego całe pole bitwy.", + "expandHud": "Rozwiń HUD przeciwnika. Pokazuje rękę każdego przeciwnika i zestawienie pola bitwy.", + "targetPlayer": "Obierz za cel {{name}}", + "viewBoardThenTarget": "Zobacz pole bitwy {{name}} (kliknij ponownie, aby obrać za cel {{name}})", + "viewBoard": "Zobacz pole bitwy {{name}}", + "clickToTarget": "Kliknij, aby obrać za cel {{name}}", + "clickToViewThenTarget": "Kliknij, aby zobaczyć pole bitwy {{name}}, a następnie kliknij ponownie, aby obrać za cel {{name}}", + "clickToViewBoard": "Kliknij, aby zobaczyć pole bitwy {{name}}", + "underAttack": "{{name}} jest atakowany", + "kickPlayer": "Wyrzuć gracza {{seat}}", + "kickPlayerTooltip": "Wyrzuć gracza (poddanie)", + "incomingAttackers_one": "{{count}} stwór cię atakuje", + "incomingAttackers_other": "{{count}} stworów cię atakuje", + "disconnected": "Rozłączony", + "connected": "Połączony", + "statHand": "Ręka", + "statCreatures": "Stwory", + "statLands": "Ziemie", + "statOther": "Inne" + }, + "combat": { + "attackAll": "Atakuj wszystkich", + "skip": "Pomiń", + "confirmAttackers": "Potwierdź atakujących ({{count}})", + "confirmBlockers": "Potwierdź blokujących ({{count}})", + "clickAttackerToAssignBlocker": "Kliknij atakującego, aby przydzielić blokującego", + "menaceRequirement": "Musi zostać zablokowany przez {{count}} lub wcale", + "blockNeedsBadge": "Potrzeba {{required}}", + "blockProgressBadge": "{{assigned}} / {{required}}", + "blockSatisfiedBadge": "✓ {{required}}", + "blockIncompleteAttacker": "{{assigned}} z {{required}} blokujących przydzielonych", + "blockIncomplete_one": "{{count}} atakujący potrzebuje kolejnego blokującego", + "blockIncomplete_other": "{{count}} atakujących potrzebuje kolejnego blokującego", + "assignDamageTitle": "Przydziel {{amount}} obrażeń bojowych", + "assignDamageSubtitle": "{{name}} — Pozostało: {{remaining}}", + "assignDamageButton": "Przydziel obrażenia", + "lethalLabel": "śmiertelne: {{amount}}", + "lethalBadge": "Śmiertelne", + "planeswalkerLoyalty": "Wędrowiec (lojalność: {{loyalty}})", + "defendingPlayerTrample": "Broniący gracz (Tratowanie)", + "pwControllerTrample": "Kontroler wędrowca (Tratowanie przez wędrowca)" + }, + "stack": { + "title": "Stos", + "collapsePanel": "Zwiń panel stosu", + "expandPanel": "Rozwiń panel stosu", + "dockLeft": "Zadokuj stos po lewej", + "dockRight": "Zadokuj stos po prawej", + "casting": "Rzucanie…", + "next": "Dalej", + "activated": "Aktywowana", + "triggered": "Wyzwolona", + "triggeredFrom": "Wyzwolona — Od {{source}}", + "controllerYou": "Ty", + "controllerOpp": "Przec", + "controllerInitialYou": "T", + "controllerInitialOpp": "G{{seat}}", + "targetingLabel": "Obiera za cel {{label}}", + "titleTargets": "Cele: {{targets}}", + "titlePaid": "Zapłacono: {{paid}}", + "titleContext": "Kontekst: {{context}}", + "paidXValue": "X={{value}}", + "paidManaSpent": "{{amount}} many", + "paidColorsSpent": "{{count}} kolorów", + "paidKicked": "kicker", + "paidKickedTimes": "kicker ×{{count}}", + "paidAdditionalCost": "dodatkowy koszt", + "paidConvoked": "convoke ×{{count}}" + }, + "targeting": { + "choosePermanentToCopy": "Wybierz trwałego do skopiowania", + "retargetCopySlot": "Zmień cel kopii: slot {{current}} z {{total}}", + "chooseTargetForCopySlot": "Wybierz cel dla kopii: slot {{current}} z {{total}}", + "chooseNewTargetForCopy": "Wybierz nowy cel dla kopii", + "chooseTargetForCopy": "Wybierz cel dla kopii", + "chooseNewTarget": "Wybierz nowy cel czaru", + "chooseNewTargetForSpell": "Wybierz nowy cel dla {{spell}}", + "chooseCreatureToExplore": "Wybierz, który stwór eksploruje jako następny", + "tapUntappedCreatures_one": "Obróć {{count}} nieobróconego stwora", + "tapUntappedCreatures_other": "Obróć {{count}} nieobróconych stworów", + "chooseTargetOf": "Wybierz cel {{current}} z {{total}}", + "chooseTarget": "Wybierz cel", + "noLegalTargets": "Brak dostępnych dozwolonych celów", + "upToOne": "do jednego {{target}}", + "one": "{{target}}", + "nounPlayer": "gracz", + "nounNonlandPermanent": "trwały niebędący ziemią", + "nounCreature": "stwór", + "nounPlaneswalker": "wędrowiec", + "nounTargetPermanent": "docelowy trwały", + "nounTarget": "cel", + "confirmTap": "Potwierdź obrócenie ({{selected}}/{{count}})", + "keepCurrentTargets": "Zachowaj bieżące cele", + "skip": "Pomiń" + }, + "log": { + "title": "Dziennik gry", + "noEvents": "Brak zdarzeń", + "closeLog": "Zamknij dziennik gry", + "verbosityFull": "pełny", + "verbosityCompact": "kompaktowy", + "verbosityMinimal": "minimalny", + "gameStarted": "Gra rozpoczęta", + "turnStarted": "Tura {{turn}} -- {{player}}", + "phaseChanged": "Faza: {{phase}}", + "priorityPassed": "{{player}} przekazał priorytet", + "spellCast": "Czar rzucony przez {{player}}", + "abilityActivated": "Zdolność aktywowana (źródło {{sourceId}})", + "zoneChangedMoved": "Obiekt {{objectId}} przeniesiony {{from}} -> {{to}}", + "zoneChangedEnters": "Obiekt {{objectId}} wchodzi do {{to}}", + "lifeChanged": "Życie {{player}}: {{sign}}{{amount}}", + "manaAdded": "{{player}} dodał manę {{manaType}}", + "permanentTapped": "Trwały {{objectId}} obrócony", + "permanentUntapped": "Trwały {{objectId}} odwrócony", + "playerLost": "{{player}} przegrał grę", + "mulliganStarted": "Faza mulligana", + "cardsDrawn_one": "{{player}} dobrał {{count}} kartę", + "cardsDrawn_other": "{{player}} dobrał {{count}} kart", + "cardDrawn": "{{player}} dobrał kartę", + "landPlayed": "{{player}} zagrał ziemię", + "stackPushed": "Obiekt {{objectId}} dodany na stos", + "stackResolved": "Wpis stosu {{objectId}} rozpatrzony", + "discarded": "{{player}} odrzucił", + "damageCleared": "Obrażenia usunięte z {{objectId}}", + "gameOverWinner": "Koniec gry -- {{player}} wygrywa!", + "gameOverDraw": "Koniec gry -- Remis", + "damageDealtPlayer": "Źródło {{sourceId}} zadaje {{amount}} obrażeń graczowi {{player}}", + "damageDealtObject": "Źródło {{sourceId}} zadaje {{amount}} obrażeń obiektowi {{objectId}}", + "spellCountered": "Obiekt {{objectId}} skontrowany przez {{counteredBy}}", + "counterAdded": "{{counterType}} x{{count}} dodano do {{objectId}}", + "counterRemoved": "{{counterType}} x{{count}} usunięto z {{objectId}}", + "tokenCreated": "Token \"{{name}}\" utworzony", + "creatureDestroyed": "Stwór {{objectId}} zniszczony", + "permanentSacrificed": "{{player}} poświęcił {{objectId}}", + "effectResolved": "Efekt {{kind}} rozpatrzony", + "attackersDeclared_one": "{{count}} atakujący zadeklarowany", + "attackersDeclared_other": "{{count}} atakujących zadeklarowanych", + "blockersDeclared_one": "{{count}} blokujący przydzielony", + "blockersDeclared_other": "{{count}} blokujących przydzielonych", + "becomesTarget": "Obiekt {{objectId}} obrany za cel przez {{sourceId}}", + "replacementApplied": "Zastosowano efekt zastępujący: {{eventType}}", + "companionRevealed": "{{player}} ujawnił towarzysza: {{cardName}}", + "companionMovedToHand": "{{player}} wziął towarzysza {{cardName}} do ręki", + "powerToughnessChanged": "Obiekt {{objectId}} ma teraz {{power}}/{{toughness}} ({{powerDelta}}/{{toughnessDelta}})", + "genericEvent": "Zdarzenie: {{type}}" + }, + "coverage": { + "engineTools": "Narzędzia silnika", + "title": "Pokrycie kart", + "subtitle": "Sprawdź pokrycie implementacji i obsługiwane handlery silnika.", + "tabCardCoverage": "Pokrycie kart", + "tabBySet": "Wg dodatku", + "tabGapAnalysis": "Analiza braków", + "tabSupportedHandlers": "Obsługiwane handlery", + "noDataAvailable": "Brak dostępnych danych o pokryciu.", + "generateHint": "Wygeneruj je za pomocą: cargo run --bin coverage-report -- /path/to/cards --all > client/public/coverage-data.json", + "emptyData": "Dane o pokryciu są puste (przeanalizowano 0 kart).", + "runHint": "Uruchom: cargo run --bin coverage-report -- /path/to/cards --all > client/public/coverage-data.json", + "searchCards": "Szukaj kart...", + "filterAll": "Wszystkie", + "filterSupported": "Obsługiwane", + "filterUnsupported": "Nieobsługiwane", + "sortAz": "Sortuj: A-Z", + "sortMostGaps": "Sortuj: Najwięcej braków", + "sortFewestGaps": "Sortuj: Najmniej braków", + "shownOfTotal": "{{shown}} z {{total}} wyświetlonych", + "noMatches": "Brak dopasowań", + "matchesCount": "{{shown}} z {{total}} dopasowań", + "cardsCount": "{{count}} kart", + "selectCardPrompt": "Wybierz kartę, aby sprawdzić jej rozbicie analizy", + "navHint": "Użyj strzałek do nawigacji, Escape do odznaczenia", + "coverageByFormat": "Pokrycie wg formatu", + "allFormats": "Wszystkie formaty", + "setThresholdHint": "Dodatki z ≥{{minCards}} kartami i ≥{{minCoverage}}% w pełni obsługiwanymi. Rozwiń dodatek, aby sprawdzić pozostałe braki.", + "setsCount": "({{count}} dodatków)", + "noSetsMeetThreshold": "Żaden dodatek nie spełnia jeszcze progu.", + "setMembershipMissing": "Dane o przynależności do dodatku nie znajdują się w tym eksporcie pokrycia.", + "regenerateHint": "Wygeneruj ponownie za pomocą: ./scripts/gen-card-data.sh", + "gapCount_one": "{{count}} brak", + "gapCount_other": "{{count}} braków", + "noUnsupportedInSet": "Brak nieobsługiwanych kart w tym dodatku.", + "unsupportedCardsHint": "Nieobsługiwane karty ({{count}}) — kliknij jedną, aby sprawdzić drzewo analizy", + "noGapDetails": "brak szczegółów braku", + "noGapAnalysis": "Brak dostępnych danych analizy braków.", + "filterByFormat": "Filtruj wg formatu:", + "topGaps": "Najważniejsze braki wg wpływu (Top 50)", + "copyAsTsv": "Kopiuj jako TSV", + "totalLabel": "{{count}} łącznie", + "unlockLabel": "{{count}} odblokuje", + "indLabel": "{{ratio}} ind", + "exampleCard": "np. {{name}}", + "oraclePatterns": "Wzorce tekstu Oracle", + "coOccurringGaps": "Współwystępujące braki", + "singleGapUnlockByFormat": "Odblokowanie jednym brakiem wg formatu", + "twoGapBundles": "Pakiety 2 braków (zaimplementuj oba, aby odblokować karty)", + "threeGapBundles": "Pakiety 3 braków (zaimplementuj wszystkie trzy, aby odblokować karty)", + "bundleCards": "{{count}} kart", + "backToList": "Powrót do listy", + "supported": "Obsługiwane", + "unsupported": "Nieobsługiwane", + "copyOracle": "Kopiuj tekst Oracle", + "unresolvedGaps": "Nierozwiązane braki ({{count}})", + "vanillaCard": "Brak przeanalizowanych elementów (karta vanilla).", + "additionalParseItems": "Dodatkowe elementy analizy", + "itemUnsupported": "nieobsługiwane", + "categoryKeyword": "Słowo kluczowe", + "categoryAbility": "Zdolność", + "categoryTrigger": "Wyzwalacz", + "categoryStatic": "Statyczna", + "categoryReplacement": "Zastępująca", + "categoryCost": "Koszt", + "copied": "Skopiowano!", + "handlersObserved": "{{count}} handlerów zaobserwowanych na obsługiwanych kartach", + "derivedFrom": "wyprowadzone z wyniku analizy · zaślepki i nieużywane warianty wykluczone", + "tabEffects": "Efekty", + "tabTriggers": "Wyzwalacze", + "tabKeywords": "Słowa kluczowe", + "tabStatics": "Statyczne", + "tabReplacements": "Zastępujące", + "search": "Szukaj...", + "cardCount_one": "{{formattedCount}} karta", + "cardCount_other": "{{formattedCount}} kart", + "noMatchesFound": "Nie znaleziono dopasowań dla „{{query}}”", + "noHandlersInCategory": "Żadne handlery z tej kategorii nie są tworzone przez parser na w pełni obsługiwanych kartach.", + "footerSummary": "{{handlers}} handlerów w {{categories}} kategoriach · wyprowadzone z silnika z {{cards}} obsługiwanych kart" + }, + "gamePage": { + "actions": { + "returnToMenu": "Powrót do menu", + "returnToMenuLower": "Powrót do menu", + "concedeGame": "Poddaj grę", + "ok": "OK" + }, + "toasts": { + "playerDisconnected": "{{name}} rozłączył się", + "gamePausedPlayerDisconnected": "Gra wstrzymana — {{name}} rozłączył się", + "roomFull": "Pokój pełny — gotowe do rozpoczęcia!", + "hostReconnecting": "Gospodarz rozłączył się — ponowne łączenie (próba {{attempt}})…", + "roomStillClaimed": "Twój poprzedni pokój wciąż jest wygaszany na serwerze sygnalizacyjnym. Odczekaj około minuty, a potem spróbuj wznowić ponownie." + }, + "sandbox": { + "banner": "Tryb piaskownicy — akcje debugowania włączone", + "bannerAria": "Baner trybu piaskownicy" + }, + "reconnect": { + "banner": "Ponowne łączenie… (próba {{attempt}})", + "bannerWithMax": "Ponowne łączenie… (próba {{attempt}}/{{maxAttempts}})", + "connectionLost": "Utracono połączenie" + }, + "opponentDisconnected": { + "title": "Przeciwnik rozłączony", + "body": "Oczekiwanie na ponowne połączenie przeciwnika..." + }, + "resumeReset": { + "message": "{{reason}} Rozpoczęto nową grę." + }, + "playDraw": { + "title": "Gra {{gameNumber}}: Wybierz zagrywanie lub dobieranie", + "matchScore": "Wynik meczu {{p0Wins}}-{{p1Wins}}", + "playFirst": "Zagrywaj pierwszy", + "playFirstDescription": "Wykonaj pierwszą turę", + "drawFirst": "Dobieraj pierwszy", + "drawFirstDescription": "Wykonaj dodatkowe dobranie w swojej pierwszej turze" + }, + "mulligan": { + "opponentDeciding": "Przeciwnik wybiera swoją rękę startową…", + "londonTitle": "Mulligan londyński ({{count}} wziętych)", + "keepHand": "Zatrzymaj rękę", + "putOnBottom": "Połóż {{count}} na spód", + "noCardsToBottom": "Brak kart na spód", + "mulligan": "Mulligan", + "freeMulligan": "Darmowy mulligan", + "mulliganTo": "Mulligan do {{count}}", + "shuffleDrawSevenFree": "Przetasuj i dobierz 7 — brak kart na spód", + "shuffleDrawSevenAgain": "Przetasuj i dobierz 7 ponownie", + "usePowder": "Użyj {{name}}", + "powderDescription": "Wygnaj każdą kartę z ręki, dobierz tyle samo — to nie mulligan", + "powderTooltip": "Wygnaj każdą kartę z ręki i dobierz tyle samo. To nie mulligan — liczba i karty na spodzie pozostają bez zmian.", + "eyebrowMulligan": "Mulligan {{count}} · Londyński", + "eyebrowOpening": "Ręka startowa · Mulligan londyński", + "reviewTitle": "Przejrzyj swoją rękę startową", + "subtitleKeepWithBottom": "Zatrzymaj tę rękę (położysz {{count}} na spód) lub zrób mulligan ponownie po świeże 7.", + "subtitleKeepFree": "Zatrzymaj tę rękę (darmowy mulligan — brak kart na spód) lub zrób mulligan ponownie po świeże 7.", + "subtitleOpeningFree": "Zatrzymaj tę rękę lub zrób darmowy mulligan (brak kart na spód).", + "subtitleOpeningOne": "Zatrzymaj tę rękę lub zrób mulligan po świeże 7 (gdy zatrzymasz, położysz 1 na spód)." + }, + "companion": { + "eyebrow": "Przed grą", + "title": "Ujawnić towarzysza?", + "subtitle": "Możesz ujawnić towarzysza ze swojego sideboardu. Zostanie umieszczony w strefie towarzysza i można go wziąć do ręki raz w trakcie gry, płacąc {3}.", + "decline": "Odmów", + "reveal": "Ujawnij {{name}}" + }, + "bottomCards": { + "eyebrowTinyLeaders": "Tiny Leaders", + "eyebrowLondon": "Mulligan londyński", + "title_one": "Połóż {{count}} kartę na spód", + "title_other": "Połóż {{count}} kart na spód", + "subtitleOpening_one": "Wybierz {{count}} kartę z ręki startowej przed rozpoczęciem mulliganów.", + "subtitleOpening_other": "Wybierz {{count}} kart z ręki startowej przed rozpoczęciem mulliganów.", + "subtitleMulligan_one": "Wybierz {{count}} kartę z ręki. Zostanie zwrócona na spód biblioteki w wybranej tutaj kolejności.", + "subtitleMulligan_other": "Wybierz {{count}} kart z ręki. Zostaną zwrócone na spód biblioteki w wybranej tutaj kolejności.", + "selectedOf": "Wybrano {{selected}} z {{count}}", + "confirmSelection": "Potwierdź wybór" + }, + "gameOver": { + "draw": "REMIS", + "victory": "ZWYCIĘSTWO", + "defeat": "PORAŻKA", + "lifeSummary": "Ty: {{playerLife}} / Przeciwnik: {{opponentLife}}", + "turns": "Tury: {{count}}", + "duration": "Czas trwania: {{time}}", + "backToDraft": "Powrót do draftu", + "continueRun": "Kontynuuj rozgrywkę", + "backToLobby": "Powrót do lobby", + "rematch": "Rewanż" + }, + "abilityChoice": { + "subtitleSneak": "Wybierz, którego atakującego zwrócić (koszt Sneak)", + "subtitlePreparedCopy": "Rzucić przygotowany czar?", + "subtitleActivate": "Aktywować tę zdolność?", + "subtitlePlay": "Wybierz, jak zagrać tę kartę", + "subtitleChoose": "Wybierz zdolność do aktywowania" + }, + "defiler": { + "title": "Redukcja kosztu Defiler", + "subtitle": "Zapłacić {{lifeCost}} życia, aby zmniejszyć koszt many?", + "payLife": "Zapłać {{lifeCost}} życia", + "decline": "Odmów" + }, + "untap": { + "permanentFallback": "Trwały", + "title": "Odwrócić {{name}}?", + "subtitle": "Wybierz, czy ten trwały odwraca się w trakcie twojego kroku odwracania.", + "untap": "Odwróć", + "untapDescription": "{{name}} odwraca się teraz.", + "keepTapped": "Zostaw obrócony", + "keepTappedDescription": "{{name}} pozostaje obrócony w tym kroku odwracania." + }, + "cost": { + "discardCard": "odrzuć kartę", + "life": "{{amount}} życia", + "sacrifice_one": "poświęć trwałego", + "sacrifice_other": "poświęć {{count}} trwałych", + "returnToHand_one": "zwróć trwałego do ręki", + "returnToHand_other": "zwróć {{count}} trwałych do ręki", + "energy": "{{amount}} energii", + "generic": "koszt", + "pay": "Zapłać {{cost}}" + }, + "unlessPayment": { + "defaultEffect": "Skontruj, chyba że zapłacisz", + "defaultChooseCost": "Wybierz koszt", + "title": "{{effect}}, chyba że zapłacisz", + "titleChooseOne": "{{effect}}, chyba że zapłacisz jeden", + "dontPay": "Nie płać", + "takeEffect": "Przyjmij efekt" + }, + "activationCost": { + "title": "Wybierz koszt aktywacji" + }, + "debug": { + "modeBanner": "TRYB DEBUGOWANIA - Kliknij dowolną kartę" + } + }, + "gameProvider": { + "passwordPrompt": "Ten pokój wymaga hasła:", + "toasts": { + "connectionFailed": "Połączenie nie powiodło się. Spróbuj ponownie lub zmień serwer w Ustawieniach.", + "eliminatedSpectating": "Zostałeś wyeliminowany. Teraz obserwujesz." + }, + "resumeReset": { + "appUpdated": "Aplikacja została zaktualizowana, a zapisana gra jest niezgodna z nową wersją.", + "restoreFailed": "Nie udało się przywrócić zapisanej gry: {{error}}" + }, + "notification": { + "title": "Przeciwnik dołączył", + "opponentJoined": "Twój przeciwnik dołączył do gry. Wróć i graj!", + "opponentJoinedNamed": "{{name}} dołączył do twojej gry. Wróć i graj!" + }, + "noLegalAiDecks": { + "withFormat": "Brak dozwolonych talii AI dla formatu {{format}}.", + "generic": "Brak dozwolonych talii AI dla wybranego formatu." + } + }, + "gameSetup": { + "eyebrow": "Konfiguracja meczu", + "title": "Rozpocznij mecz.", + "startMatch": "Rozpocznij mecz", + "startMatchWithOpponents": "Rozpocznij mecz ({{count}} przec.)", + "deckNotLegal": "Talia nie jest dozwolona w formacie {{format}}.", + "selectDeckPrompt": "Wybierz talię", + "commanderNote": "Commander: 100 kart singleton, obrażenia od dowódcy przy {{threshold}}", + "formatChip": { + "kicker": "Format", + "choosePlaceholder": "Wybierz format", + "ariaLabel": "Format: {{label}} ({{group}}). Dotknij, aby zmienić.", + "ariaLabelEmpty": "Wybierz format meczu" + }, + "formatPicker": { + "title": "Wybierz format", + "subtitle": "Wybierz zasady, według których będą grać wszyscy przy stole." + }, + "deckPreview": { + "editDeck": "Edytuj {{name}}", + "cardCount_one": "{{count}} karta", + "cardCount_other": "{{count}} kart", + "unknownBadge": "Nieznane {{count}}", + "unknownCardsTitle": "Nieznane karty:\n{{cards}}" + }, + "config": { + "startingLife": "Życie początkowe", + "players": "Gracze", + "whoGoesFirst": "Kto zaczyna", + "firstPlayer": { + "random": "Losowo", + "play": "Zagrywanie", + "draw": "Dobieranie" + } + }, + "noLegalAiDecks": { + "withFormat": "Dodaj lub zaimportuj talię dozwoloną w formacie {{format}}, aby AI mogło jej użyć.", + "generic": "Dodaj lub zaimportuj talię dozwoloną w tym formacie, aby AI mogło jej użyć." + } + }, + "cardChoice": { + "badges": { + "choose": "Wybierz", + "selected": "Wybrano", + "keep": "Zatrzymaj", + "top": "Wierzch", + "bottom": "Spód", + "graveyard": "Cmentarz", + "put": "Połóż", + "sacrifice": "Poświęć", + "exile": "Wygnaj", + "battlefield": "Pole bitwy", + "target": "Cel", + "return": "Zwróć", + "discard": "Odrzuć", + "evidence": "Dowody", + "manifest": "Manifest", + "crew": "Crew ({{power}})", + "saddle": "Saddle ({{power}})", + "station": "Station (+{{power}})" + }, + "buttons": { + "confirm": "Potwierdź", + "decline": "Odmów", + "pass": "Przekaż", + "skip": "Pomiń", + "repeat": "Powtórz", + "stop": "Zatrzymaj", + "confirmCount": "Potwierdź ({{selected}}/{{count}})", + "confirmOrder": "Potwierdź kolejność ({{selected}}/{{count}})", + "sacrificeCount": "Poświęć ({{selected}}/{{count}})", + "exileCount": "Wygnaj ({{selected}}/{{count}})", + "discardCount": "Odrzuć ({{selected}}/{{count}})", + "collectCount": "Zbierz ({{total}}/{{minimum}})", + "labelCount": "{{label}} ({{selected}}/{{count}})" + }, + "ringBearer": { + "title": "Wybierz Nosiciela Pierścienia", + "subtitle": "Wybierz stwora, którego kontrolujesz" + }, + "reorderHint": "Przeciągnij, aby zmienić kolejność · karta na wierzchu jest dobierana pierwsza", + "scry": { + "title": "Scry", + "subtitle_one": "Spójrz na {{count}} kartę z wierzchu swojej biblioteki", + "subtitle_other": "Spójrz na {{count}} kart z wierzchu swojej biblioteki" + }, + "dig": { + "titleReorder": "Zmień kolejność kart", + "title": "Wybierz karty", + "subtitleReorder": "Wybierz wszystkie {{count}} kart w kolejności od wierzchu do spodu", + "subtitleExact_one": "Wybierz {{count}} kartę, aby położyć {{destination}}", + "subtitleExact_other": "Wybierz {{count}} kart, aby położyć {{destination}}", + "subtitleUpTo_one": "Wybierz do {{count}} karty, aby położyć {{destination}}", + "subtitleUpTo_other": "Wybierz do {{count}} kart, aby położyć {{destination}}", + "destinationTop": "na wierzchu twojej biblioteki", + "destinationBattlefield": "na pole bitwy", + "destinationHand": "do twojej ręki" + }, + "surveil": { + "title": "Surveil", + "subtitle_one": "Spójrz na {{count}} kartę z wierzchu swojej biblioteki", + "subtitle_other": "Spójrz na {{count}} kart z wierzchu swojej biblioteki" + }, + "reveal": { + "titleReveal": "Ujawnij z ręki", + "titleOpponentHand": "Ręka przeciwnika", + "subtitleReveal": "Wybierz kartę do ujawnienia lub odmów", + "subtitleChoose": "Wybierz kartę" + }, + "search": { + "title": "Przeszukaj bibliotekę", + "subtitleExact_one": "Wybierz {{count}} kartę", + "subtitleExact_other": "Wybierz {{count}} kart", + "subtitleUpTo_one": "Wybierz do {{count}} karty", + "subtitleUpTo_other": "Wybierz do {{count}} kart", + "subtitleMatchExact_one": "Wybierz {{count}} kartę spełniającą wymienione kryteria wyszukiwania", + "subtitleMatchExact_other": "Wybierz {{count}} kart spełniających wymienione kryteria wyszukiwania", + "subtitleMatchUpTo_one": "Wybierz do {{count}} karty spełniającej wymienione kryteria wyszukiwania", + "subtitleMatchUpTo_other": "Wybierz do {{count}} kart spełniających wymienione kryteria wyszukiwania", + "subtitleDistinctExact_one": "Wybierz {{count}} kartę o odrębnych cechach", + "subtitleDistinctExact_other": "Wybierz {{count}} kart o odrębnych cechach", + "subtitleDistinctUpTo_one": "Wybierz do {{count}} karty o odrębnych cechach", + "subtitleDistinctUpTo_other": "Wybierz do {{count}} kart o odrębnych cechach", + "subtitleManaValueExact_one": "Wybierz {{count}} kartę w granicach limitu wartości many", + "subtitleManaValueExact_other": "Wybierz {{count}} kart w granicach limitu wartości many", + "subtitleManaValueUpTo_one": "Wybierz do {{count}} karty w granicach limitu wartości many", + "subtitleManaValueUpTo_other": "Wybierz do {{count}} kart w granicach limitu wartości many" + }, + "searchPartition": { + "title": "Wybierz karty na pole bitwy", + "subtitle_one": "Wybierz {{count}} kartę, aby położyć na pole bitwy{{tapped}}; reszta trafia do twojej ręki", + "subtitle_other": "Wybierz {{count}} kart, aby położyć na pole bitwy{{tapped}}; reszta trafia do twojej ręki", + "tapped": " obrócone" + }, + "outsideGame": { + "title": "Wybierz z sideboardu", + "subtitleExact": "Wybierz {{count}}", + "subtitleUpTo": "Wybierz do {{count}}" + }, + "chooseFromZone": { + "title": "Wybierz karty", + "subtitleExact_one": "Wybierz {{count}} kartę", + "subtitleExact_other": "Wybierz {{count}} kart", + "subtitleUpTo_one": "Wybierz do {{count}} karty", + "subtitleUpTo_other": "Wybierz do {{count}} kart", + "subtitleDistinctCardTypes": "Wybierz do {{count}} kart o odrębnych typach kart" + }, + "pair": { + "title": "Wybierz partnera Soulbond", + "subtitle": "Połącz z niepołączonym stworem, którego kontrolujesz" + }, + "effectZone": { + "titleSacrifice": "Poświęć", + "titleTopdeck": "Połóż na bibliotekę", + "titleBattlefield": "Połóż na pole bitwy", + "subtitleSacrificeExact_one": "Wybierz {{count}} trwałego do poświęcenia", + "subtitleSacrificeExact_other": "Wybierz {{count}} trwałych do poświęcenia", + "subtitleSacrificeUpTo_one": "Wybierz do {{count}} trwałego do poświęcenia", + "subtitleSacrificeUpTo_other": "Wybierz do {{count}} trwałych do poświęcenia", + "subtitleSacrificeRange_one": "Wybierz {{min}}-{{count}} trwałego do poświęcenia", + "subtitleSacrificeRange_other": "Wybierz {{min}}-{{count}} trwałych do poświęcenia", + "subtitleTopdeckExact_one": "Wybierz {{count}} kartę, aby położyć na wierzchu swojej biblioteki", + "subtitleTopdeckExact_other": "Wybierz {{count}} kart, aby położyć na wierzchu swojej biblioteki", + "subtitleTopdeckUpTo_one": "Wybierz do {{count}} karty, aby położyć na wierzchu swojej biblioteki", + "subtitleTopdeckUpTo_other": "Wybierz do {{count}} kart, aby położyć na wierzchu swojej biblioteki", + "subtitleTopdeckRange_one": "Wybierz {{min}}-{{count}} kartę, aby położyć na wierzchu swojej biblioteki", + "subtitleTopdeckRange_other": "Wybierz {{min}}-{{count}} kart, aby położyć na wierzchu swojej biblioteki", + "subtitleBattlefieldExact_one": "Wybierz {{count}} kartę, aby położyć na pole bitwy", + "subtitleBattlefieldExact_other": "Wybierz {{count}} kart, aby położyć na pole bitwy", + "subtitleBattlefieldUpTo_one": "Wybierz do {{count}} karty, aby położyć na pole bitwy", + "subtitleBattlefieldUpTo_other": "Wybierz do {{count}} kart, aby położyć na pole bitwy", + "subtitleBattlefieldRange_one": "Wybierz {{min}}-{{count}} kartę, aby położyć na pole bitwy", + "subtitleBattlefieldRange_other": "Wybierz {{min}}-{{count}} kart, aby położyć na pole bitwy", + "labelSkip": "Pomiń", + "labelDecline": "Odmów", + "labelPutOnTop": "Połóż na wierzch ({{order}})", + "labelConfirm": "Potwierdź ({{selected}}/{{count}})", + "labelTop": "Wierzch ({{selected}}/{{count}})", + "labelPut": "Połóż ({{selected}}/{{count}})", + "orderTop": "Wierzch" + }, + "drawnThisTurn": { + "title": "Dobrane w tej turze", + "subtitle": "Połóż do {{count}} na wierzch; zapłać {{life}} życia za każdą zatrzymaną", + "labelPayLife": "Zapłać {{life}} życia", + "labelConfirm": "Potwierdź ({{selected}}/{{count}})" + }, + "sacrifice": { + "title": "Poświęć", + "subtitle_one": "Wybierz {{count}} trwałego do poświęcenia", + "subtitle_other": "Wybierz {{count}} trwałych do poświęcenia" + }, + "exileBattlefield": { + "title": "Wygnaj", + "subtitle_one": "Wybierz {{count}} trwałego do wygnania", + "subtitle_other": "Wybierz {{count}} trwałych do wygnania" + }, + "multiTarget": { + "title": "Wybierz cele", + "subtitleExact_one": "Wybierz {{count}} cel", + "subtitleExact_other": "Wybierz {{count}} celów", + "subtitleRange": "Wybierz {{min}}–{{max}} celów" + }, + "paradigm": { + "title": "Paradigm", + "subtitle": "Rzuć kopię jednego z tych czarów bez płacenia jego kosztu many lub przekaż." + }, + "payManaAbility": { + "title": "Zapłać koszt zdolności manowej", + "subtitle": "Wybierz, którą manę wydać" + }, + "returnToHand": { + "title": "Zwróć", + "subtitle_one": "Wybierz {{count}} trwałego do zwrócenia", + "subtitle_other": "Wybierz {{count}} trwałych do zwrócenia" + }, + "removeCounter": { + "title": "Usuń znacznik", + "subtitle": "Wybierz trwałego, z którego usunąć znacznik", + "label": "Usuń" + }, + "blight": { + "title": "Blight", + "subtitle_one": "Połóż znacznik -1/-1 na {{count}} stwora, którego kontrolujesz", + "subtitle_other": "Połóż znacznik -1/-1 na {{count}} stworów, których kontrolujesz" + }, + "crew": { + "title": "Załoguj pojazd", + "subtitle": "Obróć stwory o łącznej sile {{power}} lub większej", + "label": "Crew ({{total}}/{{power}})" + }, + "station": { + "title": "Station", + "subtitle": "Obróć innego nieobróconego stwora, którego kontrolujesz. Dodana liczba znaczników ładunku równa jest jego sile.", + "labelWithCharge": "Station (+{{charge}} ładunku)", + "label": "Station" + }, + "saddle": { + "title": "Osiodłaj wierzchowca", + "subtitle": "Obróć stwory o łącznej sile {{power}} lub większej", + "label": "Saddle ({{total}}/{{power}})" + }, + "wardSacrifice": { + "title_one": "Ward — Poświęć trwałego", + "title_other": "Ward — Poświęć {{count}} trwałych", + "subtitle": "Wybierz trwałego do poświęcenia" + }, + "unlessBounce": { + "title_one": "Zwróć trwałego do ręki", + "title_other": "Zwróć {{count}} trwałych do ręki", + "subtitle": "Wybierz trwałego do zwrócenia do ręki jego właściciela" + }, + "exileForCost": { + "titleAlternative": "Koszt alternatywny", + "titleEscape": "Escape", + "sourceHand": "twojej ręki", + "sourceGraveyard": "twojego cmentarza", + "subtitle_one": "Wygnaj {{count}} kartę z {{source}}", + "subtitle_other": "Wygnaj {{count}} kart z {{source}}" + }, + "behold": { + "title": "Behold", + "subtitleExile": "Wygnaj pasującego trwałego lub kartę", + "subtitleChoose": "Wybierz pasującego trwałego lub ujawnij pasującą kartę", + "labelExile": "Wygnaj", + "labelBehold": "Behold" + }, + "collectEvidence": { + "title": "Zbierz dowody", + "subtitle": "Wygnaj karty ze swojego cmentarza o łącznej wartości many {{minimum}} lub większej" + }, + "discard": { + "title": "Odrzuć", + "titleAdditionalCost": "Odrzuć jako koszt dodatkowy", + "titleManaAbility": "Odrzuć dla zdolności manowej", + "titleConnive_one": "Connive — Odrzuć kartę", + "titleConnive_other": "Connive — Odrzuć {{count}} kart", + "titleUpTo": "Odrzuć do {{count}} kart", + "titleExact_one": "Odrzuć kartę", + "titleExact_other": "Odrzuć {{count}} kart", + "titleWard": "Ward — Odrzuć kartę", + "subtitleUpTo_one": "Wybierz do {{count}} karty do odrzucenia", + "subtitleUpTo_other": "Wybierz do {{count}} kart do odrzucenia", + "subtitleUnless": "Wybierz {{count}} kart lub 1 pasującą kartę do odrzucenia", + "subtitleExact_one": "Wybierz {{count}} kartę do odrzucenia", + "subtitleExact_other": "Wybierz {{count}} kart do odrzucenia" + }, + "harmonize": { + "title": "Harmonize", + "subtitle": "Obróć stwora, aby zmniejszyć koszt rzucania o jego siłę, lub pomiń", + "labelSkip": "Pomiń (zapłać pełny koszt)" + }, + "legend": { + "title": "Zasada legend", + "subtitle": "Wybierz, którego „{{name}}” zatrzymać", + "keepAria": "Zatrzymaj {{name}} ({{status}})", + "statusJustEntered": "Właśnie wszedł", + "statusAlready": "Już na polu bitwy" + }, + "commanderZone": { + "title": "Strefa dowodzenia", + "subtitle": "{{name}} został umieszczony w strefie {{zone}}. Zwrócić do strefy dowodzenia?", + "commanderFallback": "Dowódca", + "labelCommandZone": "Strefa dowodzenia", + "labelLeave": "Zostaw w {{zone}}" + }, + "revealUntil": { + "title": "Ujawniaj, aż", + "subtitle": "Położyć {{name}} na pole bitwy?", + "cardFallback": "ujawnioną kartę", + "labelBattlefield": "Na pole bitwy", + "labelInto": "Do {{zone}}" + }, + "repeatProcess": { + "title": "Powtórz ten proces", + "subtitle": "Powtórzyć ten proces ponownie?" + }, + "damageSource": { + "title": "Źródło obrażeń", + "subtitle": "Wybierz źródło" + }, + "manifestDread": { + "title": "Manifest Dread", + "subtitle": "Wybierz kartę do zmanifestowania zakrytej. Druga trafi na twój cmentarz.", + "label": "Potwierdź Manifest" + }, + "manaColor": { + "title": "Wybierz kolor many", + "subtitleBatch": "Wybierz kolor, a następnie ile źródeł obrócić", + "subtitle": "Wybierz, jaki kolor many wytworzyć", + "howMany": "Ile?", + "tapFewer": "Obróć mniej", + "tapMore": "Obróć więcej", + "labelAdd": "Dodaj {{count}}", + "labelConfirm": "Potwierdź" + }, + "manaCombination": { + "title": "Wybierz kombinację many", + "subtitleAny": "Wybierz każdy kolor many do wytworzenia", + "subtitle": "Wybierz, jaką kombinację many wytworzyć" + } + }, + "dialogShell": { + "eyebrow": "Wybór w grze", + "restoreDialog": "Przywróć okno dialogowe", + "peekAria": "Odsuń okno dialogowe na bok", + "peekTitle": "Podejrzyj pole bitwy", + "close": "Zamknij", + "closeTitle": "Zamknij (Esc)" + }, + "choiceOverlay": { + "eyebrow": "Wybór w grze", + "scrollLeft": "Przewiń w lewo", + "scrollRight": "Przewiń w prawo", + "confirm": "Potwierdź", + "cancel": "Anuluj" + }, + "adventureCast": { + "eyebrow": "Adventure", + "title": "Wybierz stronę", + "subtitle": "Rzuć jako stwór lub jako czar Adventure.", + "adventureFallback": "Adventure", + "castNamed": "Rzuć {{name}}", + "creatureTag": "(Stwór)", + "adventureTag": "(Adventure)" + }, + "alternativeCost": { + "title": "Wybierz koszt rzucania", + "warpEyebrow": "Warp", + "warpNormalLabel": "Rzuć normalnie", + "warpAltLabel": "Rzuć z Warp", + "warpAltSuffix": "(wygnanie w kroku końcowym)", + "warpSubtitle": "Rzuć {{name}} normalnie lub użyj jego kosztu Warp.", + "evokeEyebrow": "Evoke", + "evokeNormalLabel": "Rzuć normalnie", + "evokeAltLabel": "Rzuć z Evoke", + "evokeSubtitle": "Rzuć {{name}} normalnie lub rzuć go za jego koszt Evoke.", + "overloadEyebrow": "Overload", + "overloadNormalLabel": "Rzuć normalnie", + "overloadAltLabel": "Rzuć z Overload", + "overloadSubtitle": "Rzuć {{name}} normalnie, obierając za cel jednego trwałego, lub zapłać jego koszt Overload, aby wpłynąć na wszystkie poprawne cele.", + "bestowEyebrow": "Bestow", + "bestowNormalLabel": "Rzuć jako stwora", + "bestowAltLabel": "Rzuć z Bestow", + "bestowSubtitle": "Rzuć {{name}} normalnie jako stwora lub zapłać jego koszt Bestow, aby rzucić go jako Aurę.", + "additionalExile": "+ Wygnaj kartę", + "additionalSacrifice": "+ Poświęć trwałego", + "additionalPayLife": "+ Zapłać życie", + "additionalDiscard": "+ Odrzuć kartę", + "additionalTapCreatures": "+ Obróć stwory", + "additionalGeneric": "+ {{type}}" + }, + "battleProtector": { + "title": "Wybierz obrońcę", + "battleFallback": "Bitwa", + "subtitle": "{{name}} potrzebuje nowego obrońcy. Wybierz, który przeciwnik będzie jej bronił." + }, + "cardDataMissing": { + "title": "Brak danych kart", + "body": "Nie znaleziono card-data.json. Gra potrzebuje definicji kart, aby grać prawdziwymi kartami.", + "generatePrompt": "Wygeneruj je, uruchamiając:", + "placeFile": "Następnie umieść plik wynikowy w client/public/card-data.json i odśwież.", + "continueAnyway": "Kontynuuj mimo to" + }, + "cascadeChoice": { + "cascadeEyebrow": "Cascade", + "discoverEyebrow": "Discover", + "title": "Rzucić {{name}}?", + "subtitleCascade": "Cascade wygnało {{name}} (wartość many poniżej {{sourceMv}}). Rzuć go bez płacenia jego kosztu many lub odmów i przetasuj wszystkie {{total}} wygnanych kart na spód swojej biblioteki.", + "subtitleDiscover": "Discover wygnało {{name}}. Rzuć go bez płacenia jego kosztu many lub weź go do ręki i przetasuj pozostałe {{missCount}} wygnanych kart na spód swojej biblioteki.", + "castNamed": "Rzuć {{name}}", + "castSuffix": "(bez płacenia jego kosztu many)", + "putIntoHand": "Weź do ręki", + "decline": "Odmów", + "discoverDeclineSuffix": "(połóż resztę na spód)", + "cascadeDeclineSuffix": "(przetasuj wszystkie wygnane karty na spód)" + }, + "castingVariant": { + "eyebrow": "Rzuć", + "title": "Wybierz rzucanie", + "variantNormal": "Rzuć normalnie", + "variantAdventure": "Rzuć jako Adventure", + "variantOmen": "Rzuć jako Omen", + "variantWarp": "Rzuć z Warp", + "variantEscape": "Rzuć z Escape", + "variantRetrace": "Rzuć z Retrace", + "variantHarmonize": "Rzuć z Harmonize", + "variantFlashback": "Rzuć z Flashback", + "variantAftermath": "Rzuć z Aftermath", + "variantGraveyardPermission": "Rzuć z cmentarza", + "variantHandPermission": "Rzuć z ręki", + "variantMiracle": "Rzuć z Miracle", + "variantMadness": "Rzuć z Madness", + "variantEvoke": "Rzuć z Evoke", + "variantSuspend": "Rzuć z Suspend", + "variantPlot": "Rzuć z Plot", + "variantForetell": "Rzuć z Foretell", + "variantOverload": "Rzuć z Overload", + "variantBestow": "Rzuć z Bestow", + "variantFallback": "Rzuć z {{type}}" + }, + "categoryChoice": { + "title": "Wybierz trwałe do zatrzymania", + "subtitleOpponent": "Wybierz po jednym trwałym każdego typu spośród trwałych niebędących ziemiami, które kontroluje {{name}}; reszta zostaje poświęcona.", + "subtitleSelf": "Wybierz po jednym trwałym każdego typu spośród trwałych niebędących ziemiami, które kontrolujesz; reszta zostaje poświęcona.", + "noneToKeep": "Brak {{category}} — nic do zatrzymania", + "objectFallback": "Obiekt {{id}}" + }, + "chooseOneOfBranch": { + "eyebrow": "Wybór", + "title": "Wybierz jedno", + "subtitle": "Wybierz opcję do rozpatrzenia.", + "optionFallback": "Opcja {{number}}" + }, + "combatTax": { + "eyebrow": "Podatek bojowy", + "titleAttack": "Zapłać, aby atakować", + "titleBlock": "Zapłać, aby blokować", + "subtitleAttack": "Jeden lub więcej atakujących jest opodatkowanych. Zapłać sumę lub usuń ich z ataku.", + "subtitleBlock": "Jeden lub więcej blokujących jest opodatkowanych. Zapłać sumę lub usuń ich z bloku.", + "declineAttack": "Odmów (usuń opodatkowanych atakujących)", + "declineBlock": "Odmów (usuń opodatkowanych blokujących)", + "perCreatureBreakdown": "Zestawienie na stwora", + "total": "Suma", + "pay": "Zapłać", + "creatureFallback": "Stwór {{id}}" + }, + "distributeAmong": { + "unitCounter": "znacznik {{counter}}", + "unitDamage": "obrażeń", + "unitLife": "życia", + "title": "Rozdziel {{total}} {{unit}}", + "subtitle": "Przydziel co najmniej 1 {{unit}} każdemu celowi. Pozostało: {{remaining}}" + }, + "dungeonChoice": { + "title": "Wybierz loch", + "subtitle": "Wybierz loch, do którego się wyprawić", + "roomTitle": "Wybierz pokój", + "roomSubtitle": "Awansuj w {{name}}" + }, + "engineLost": { + "crashTitle": "Silnik uległ awarii", + "crashBody": "phase.rs napotkał wewnętrzny błąd i nie może bezpiecznie kontynuować tej akcji. Twoja ostatnia zapisana tura jest zachowana — odśwież, aby przywrócić grę. Zgłoś to, abyśmy mogli to naprawić.", + "connectionTitle": "Utracono połączenie z silnikiem", + "connectionBody": "phase.rs utracił połączenie z silnikiem gry — najczęściej spowodowane aktualizacją w tle aktywowaną w trakcie gry. Twoja ostatnia zapisana tura jest zachowana; odśwież, aby przywrócić grę.", + "diagnostic": "diagnostyka: {{reason}}", + "showDetails": "Pokaż szczegóły", + "copyDiagnostic": "Kopiuj diagnostykę", + "copied": "Skopiowano!", + "reportOnGithub": "Zgłoś na GitHub", + "reload": "Odśwież", + "reportTitle": "Awaria silnika: {{summary}}", + "reportConnectionSummary": "Utracono połączenie z silnikiem", + "reportWhatHappened": "**Co się stało**\n_opisz krótko, co robiłeś_\n\n**Diagnostyka**\n```\n{{diagnostic}}\n```", + "copyPrompt": "Skopiuj tę diagnostykę:" + }, + "miracleReveal": { + "eyebrowMiracle": "Miracle", + "eyebrowMadness": "Madness", + "titleReveal": "Ujawnić {{name}}?", + "titleCast": "Rzucić {{name}}?", + "subtitleReveal": "Możesz ujawnić tę kartę, aby rzucić ją za jej koszt Miracle.", + "subtitleCastMiracle": "Możesz rzucić tę kartę za jej koszt Miracle.", + "subtitleCastMadness": "Możesz rzucić tę kartę za jej koszt Madness.", + "reveal": "Ujawnij", + "cast": "Rzuć", + "decline": "Odmów" + }, + "modalFace": { + "eyebrow": "Modalny DFC", + "title": "Wybierz stronę", + "subtitle": "Wybierz, którą stronę zagrać lub rzucić.", + "backFaceFallback": "Tylna strona", + "labelFront": "Przód", + "labelBack": "Tył", + "play": "Zagraj {{name}}", + "cast": "Rzuć {{name}}" + }, + "modeChoice": { + "eyebrowAbility": "Tryby zdolności", + "eyebrowSpell": "Tryby czaru", + "subtitle": "Wybierz tryb lub tryby do zastosowania.", + "chooseExact": "Wybierz {{count}}", + "chooseRange": "Wybierz od {{min}} do {{max}}", + "confirm": "Potwierdź ({{selected}}/{{count}})", + "clear": "Wyczyść", + "alreadyChosen": "(już wybrane)" + }, + "namedChoice": { + "title": { + "creatureType": "Wybierz typ stwora", + "color": "Wybierz kolor", + "oddOrEven": "Wybierz nieparzyste lub parzyste", + "basicLandType": "Wybierz typ ziemi podstawowej", + "cardType": "Wybierz typ karty", + "cardName": "Nazwij kartę", + "landType": "Wybierz typ ziemi", + "opponent": "Wybierz przeciwnika", + "player": "Wybierz gracza", + "twoColors": "Wybierz dwa kolory", + "numberRange": "Wybierz liczbę", + "labeled": "Dokonaj wyboru", + "keyword": "Wybierz zdolność", + "fallback": "Dokonaj wyboru" + }, + "searchSubtitle": "Pisz, aby przeszukać wszystkie karty", + "searchPlaceholder": "Szukaj po nazwie...", + "noCardsFound": "Nie znaleziono kart", + "buttonSubtitle": "Wybierz jedną opcję" + }, + "nonFatalPanic": { + "heading": "Ostrzeżenie silnika (niekrytyczne)", + "dismiss": "Odrzuć", + "body": "phase.rs napotkał wewnętrzne ostrzeżenie, ale odzyskał sprawność. Można bezpiecznie kontynuować grę. Prosimy zgłosić to, abyśmy mogli zbadać sprawę.", + "dontShowAgain": "Nie pokazuj ponownie w tej sesji", + "reportTitle": "Niekrytyczna awaria silnika: {{summary}}", + "reportWhatHappened": "**Co się stało**\n_opisz krótko, co robiłeś_\n\n**Diagnostyka**\n```\n{{diagnostic}}\n```" + }, + "optionalEffect": { + "sourceFallback": "Efekt", + "title": "{{name}} - Efekt opcjonalny", + "yes": "Tak", + "no": "Nie", + "dontAskAgain": "Nie pytaj ponownie w tej grze" + }, + "permanentTypeSlot": { + "eyebrow": "Typ trwałego", + "title": "Wybierz slot typu", + "subtitle": "{{name}} ma wiele typów trwałego. Wybierz, którego slotu typu użyć." + }, + "proliferate": { + "proliferateTitle": "Proliferate", + "proliferateSubtitle": "Wybierz dowolną liczbę trwałych i graczy ze znacznikami. Każdy wybrany cel otrzymuje po jednym dodatkowym znaczniku każdego rodzaju, który już tam jest.", + "chooseObjectsTitle": "Wybierz trwałe", + "chooseObjectsSubtitle": "Wybierz dowolną liczbę trwałych. Płacisz koszt za każdego wybranego.", + "selectAll": "Wszystko", + "selectNone": "Żaden", + "selectMine": "Moja strona", + "confirm": "Potwierdź" + }, + "replacement": { + "eyebrow": "Kolejność rozpatrywania", + "title": "Efekty zastępujące", + "subtitle": "Wybierz, który efekt zastępujący zastosować jako pierwszy.", + "candidateFallback": "Efekt zastępujący {{number}}" + }, + "retargetChoice": { + "title": "Zmień cel", + "scopeSingle": "Wybierz nowy cel dla czaru", + "scopeMulti": "Wybierz nowe cele dla czaru", + "subtitle": "{{scope}}. Obecnie: {{current}}", + "confirm": "Potwierdź", + "badgeNewTarget": "Nowy cel" + }, + "separatePiles": { + "chooserName": "Gracz {{number}}", + "partitionTitle": "Rozdziel na dwa stosy", + "partitionSubtitle": "Rozdziel swoje stwory na dwa stosy. {{chooser}} wybierze jeden.", + "partitionSubtitleRemaining_one": "Rozdziel swoje stwory na dwa stosy. {{chooser}} wybierze jeden. ({{count}} gracz więcej po tobie)", + "partitionSubtitleRemaining_other": "Rozdziel swoje stwory na dwa stosy. {{chooser}} wybierze jeden. ({{count}} graczy więcej po tobie)", + "pileALabel": "Stos A:", + "pileBLabel": "Stos B:", + "pileAria": "{{name}} — stos {{pile}}", + "pileBadge": "Stos {{pile}}", + "chooseTitle": "Wybierz stos", + "chooseSubtitle": "Wybierz jeden ze stosów gracza {{subject}}.", + "chooseSubtitleRemaining_one": "Wybierz jeden ze stosów gracza {{subject}}. ({{count}} podział więcej po tym)", + "chooseSubtitleRemaining_other": "Wybierz jeden ze stosów gracza {{subject}}. ({{count}} podziałów więcej po tym)", + "pileHeader": "Stos {{pile}} ({{count}})", + "empty": "(pusty)", + "choosePileA": "Wybierz stos A", + "choosePileB": "Wybierz stos B" + }, + "tribute": { + "sourceFallback": "Stwór z Tribute", + "title": "Tribute — {{name}}", + "subtitle_one": "Położyć {{count}} znacznik +1/+1 na {{name}}?", + "subtitle_other": "Położyć {{count}} znaczników +1/+1 na {{name}}?", + "payLabel": "Zapłać Tribute", + "payDescription_one": "Połóż {{count}} znacznik +1/+1 na {{name}}.", + "payDescription_other": "Połóż {{count}} znaczników +1/+1 na {{name}}.", + "declineLabel": "Odmów", + "declineDescription": "Odmów Tribute. Wyzwala „jeśli Tribute nie został zapłacony”." + }, + "triggerOrder": { + "eyebrow": "Kolejność rozpatrywania", + "title": "Ustal kolejność swoich zdolności wyzwalanych", + "subtitle": "Wybierz kolejność, w jakiej te wyzwalacze trafią na stos. Wierzch tej listy rozpatrywany jest jako ostatni (CR 405.3).", + "confirmOrder": "Potwierdź kolejność", + "resolvesLast": "Rozpatrywany jako ostatni (spód stosu)", + "resolvesFirst": "Rozpatrywany jako pierwszy (wierzch stosu)", + "moveUp": "Przesuń w górę", + "moveDown": "Przesuń w dół", + "triggerFallback": "Wyzwalacz {{number}}" + }, + "unhandledWaitingFor": { + "title": "Wymagana akcja, ale brakuje interfejsu", + "body": "Gra oczekuje na twoją akcję, ale ta wersja phase.rs nie ma jeszcze do tego interfejsu. To błąd — prosimy go zgłosić, abyśmy mogli go naprawić. Użyj przycisku poniżej, aby opuścić grę.", + "missingState": "Brakujący stan", + "copyDiagnostic": "Kopiuj diagnostykę", + "copied": "Skopiowano!", + "reportOnGithub": "Zgłoś na GitHub", + "copyPrompt": "Skopiuj tę diagnostykę:", + "reportTitle": "Nieobsłużony WaitingFor: {{type}}", + "reportWhatHappened": "**Co się stało**\n_opisz krótko, co robiłeś_\n\n**Diagnostyka**\n```\n{{diagnostic}}\n```" + }, + "voteChoice": { + "subjectName": "Gracz {{number}}", + "titleLabel": "Oznacz gracza", + "titleVote": "Głosuj", + "subtitleLabel": "Wybierz oznaczenie dla {{name}}", + "subtitleVoteRemaining": "Oddaj głos (pozostało {{count}})", + "subtitleVote": "Oddaj swój głos" + }, + "advance": { + "resolve": "Rozpatrz", + "passPriority": "Przekaż priorytet", + "toPhase": "Do {{phase}}" + }, + "outsideGame": { + "fromExile": "Z wygnania", + "fromSideboard": "Z zaplecza" + }, + "phaseName": { + "Upkeep": "Utrzymanie", + "Draw": "Dobieranie", + "PreCombatMain": "Główna faza 1", + "BeginCombat": "Początek walki", + "DeclareAttackers": "Deklaracja atakujących", + "DeclareBlockers": "Deklaracja blokujących", + "CombatDamage": "Obrażenia bojowe", + "EndCombat": "Koniec walki", + "PostCombatMain": "Główna faza 2", + "End": "Krok końcowy", + "Cleanup": "Sprzątanie" + } +} diff --git a/client/src/i18n/locales/pl/menu.json b/client/src/i18n/locales/pl/menu.json new file mode 100644 index 0000000000..7837ac0929 --- /dev/null +++ b/client/src/i18n/locales/pl/menu.json @@ -0,0 +1,233 @@ +{ + "backButton": { + "label": "Wstecz" + }, + "aiDifficulty": { + "label": "Poziom trudności AI", + "ariaLabel": "Poziom trudności AI: {{difficulty}}", + "levels": { + "VeryEasy": "Bardzo łatwy", + "Easy": "Łatwy", + "Medium": "Średni", + "Hard": "Trudny", + "VeryHard": "Bardzo trudny" + } + }, + "bracketFilter": { + "label": "Filtr poziomu" + }, + "gamePresets": { + "heading": "Szybki start" + }, + "shell": { + "disclaimer": "phase.rs to niekomercyjny projekt fanowski, niepowiązany z Wizards of the Coast ani przez nich nieautoryzowany. Magic: The Gathering jest © Wizards of the Coast LLC. Obrazy kart są pobierane ze Scryfall w czasie działania; dane kart pochodzą z MTGJSON. Żadne objęte prawem autorskim zasoby WotC nie są dołączone do tego projektu." + }, + "home": { + "resume": { + "title": "Wznów grę", + "description": "Kontynuuj ostatnio zapisany pojedynek od bieżącej tury i stanu pola gry." + }, + "setup": { + "title": "Graj z AI", + "titleSaved": "Nowy pojedynek z AI", + "description": "Rozegraj samotny pojedynek przeciwko przeciwnikowi AI — wybierz format, talię, archetyp i poziom trudności." + }, + "online": { + "title": "Graj online", + "description": "Załóż pokój, dołącz przez kod lub połącz się ponownie z trybem wieloosobowym." + }, + "draft": { + "title": "Draft", + "description": "Szybki draft przeciwko AI oraz eksperymentalne opcje draftu kostkowego i podu." + }, + "decks": { + "title": "Talie", + "description": "Otwórz zapisane talie, zmień aktywną listę i edytuj konstrukcje." + }, + "social": { + "sponsor": "Wesprzyj" + }, + "preview": { + "cta": "Wypróbuj wersję zapowiadającą", + "tooltip": "Zagraj w najnowszą wersję zapowiadającą — nowe karty i poprawki trafiają tu przed każdym wydaniem." + }, + "coverage": { + "title": "Otwórz panel obsługi kart", + "ariaLabel": "Otwórz panel obsługi kart", + "heading": "Panel obsługi kart", + "viewDetails": "Zobacz szczegóły" + }, + "preparingCards": "Przygotowywanie kart…", + "alpha": { + "label": "Wczesna alfa", + "message": " — spodziewaj się niedziałających kart i brakujących funkcji." + }, + "savedMatchAvailable": "Dostępny zapisany pojedynek", + "load": { + "title": "Wczytaj stan gry" + }, + "exit": "Wyjdź" + }, + "loadState": { + "title": "Wczytaj stan gry", + "subtitle": "Przywróć wyeksportowany lub wklejony stan i kontynuuj grę przeciwko AI.", + "tabPaste": "Wklej JSON", + "tabFile": "Z pliku", + "pastePlaceholder": "Wklej JSON stanu gry…", + "fileSupports": "Obsługuje pliki .json, .txt i .zip wyeksportowane z panelu debugowania", + "chooseFile": "Wybierz plik", + "load": "Wczytaj" + }, + "aiOpponent": { + "heading": "Przeciwnik AI", + "headingMulti": "Przeciwnicy AI ({{count}})", + "analyzingDecks": "Analizowanie talii…", + "noLegalDecks": "Brak dozwolonych talii AI dla tego formatu.", + "catalogUnavailable": "Katalog talii AI niedostępny: {{error}}", + "randomPoolFilters": "Filtry losowej puli", + "archetype": "Archetyp", + "cardCoverage": "Obsługa kart", + "coverageThresholdHint": "Wyklucz talie poniżej tego progu obsługi przez silnik", + "bracket": "Poziom", + "bracketHint": "Losowe AI wybiera z tych poziomów. Talie bez tagów są wykluczane podczas filtrowania.", + "opponentLabel": "Przeciwnik {{number}}", + "deck": "Talia", + "difficulty": "Poziom trudności", + "deckRandom": "Losowa", + "deckRandomCount": "Losowa ({{count}})", + "source": { + "feed": "Kanał", + "user": "Użytkownik", + "precon": "Precon" + } + }, + "feedManager": { + "title": "Zarządzaj kanałami", + "refreshing": "Odświeżanie…", + "refreshAll": "Odśwież wszystkie", + "refresh": "Odśwież", + "subscribe": "Subskrybuj", + "unsubscribe": "Anuluj subskrypcję", + "lastRefreshed": "Ostatnio odświeżono: {{date}}", + "addCustomFeed": "Dodaj własny kanał", + "add": "Dodaj", + "done": "Gotowe" + }, + "importDeck": { + "title": "Importuj talię", + "tabPaste": "Wklej tekst", + "tabFile": "Z pliku", + "deckNamePlaceholder": "Nazwa talii", + "import": "Importuj", + "fileSupports": "Obsługuje formaty .dck, .dec, .txt oraz MTGA", + "chooseFile": "Wybierz plik" + }, + "precon": { + "savePrompt": "Zapisz gotową talię jako:", + "overwriteConfirm": "„{{name}}” już istnieje. Nadpisać?", + "overwriteAllConfirm": "Wszystkie {{count}} wybrane talie już istnieją. Nadpisać?", + "overwriteSomeConfirm": "{{conflicts}} z {{total}} wybranych talii już istnieje. Nadpisać duplikaty? (Anuluj zachowuje istniejące kopie i importuje resztę.)", + "importedSkipped_one": "Zaimportowano {{count}} talię; pominięto {{skipped}} istniejących.", + "importedSkipped_other": "Zaimportowano {{count}} talii; pominięto {{skipped}} istniejących.", + "title": "Gotowe talie", + "subtitle": "Pochodzą z MTGJSON AllDeckFiles — każdy precon wydany przez WotC.", + "totalCount": "· {{count}} łącznie", + "searchPlaceholder": "Szukaj według nazwy, kodu dodatku lub typu…", + "allTypes": "Wszystkie ({{count}})", + "loadingCatalog": "Wczytywanie katalogu talii…", + "noMatch": "Brak pasujących talii.", + "selectDeck": "Wybierz {{name}}", + "cardCount_one": "{{count}} karta", + "cardCount_other": "{{count}} kart", + "commanderSuffix": " · dow.", + "showingFirst": "Wyświetlono pierwsze {{count}} — zawęź wyszukiwanie.", + "selectAllVisible": "Zaznacz wszystkie widoczne", + "clearSelection": "Wyczyść ({{count}})", + "importSelected": "Importuj {{count}} wybranych" + }, + "deckTile": { + "feedBadge": "Kanał", + "edit": "Edytuj {{name}}", + "delete": "Usuń", + "deleteTitle": "Usuń talię", + "copy": "Kopiuj do Moich talii", + "copyTitle": "Kopiuj do Moich talii (usuwa śledzenie kanału)", + "cardCount_one": "{{count}} karta", + "cardCount_other": "{{count}} kart", + "preconBadge": "precon", + "unknown": "Nieznane {{count}}", + "unknownCardsTitle": "Nieznane karty:\n{{cards}}", + "unsupportedTitle": "Nieobsługiwane ({{unique}} unikalnych, {{copies}} kopii):\n{{cards}}", + "setIconAlt": "Ikona dodatku {{code}}" + }, + "myDecks": { + "headingManage": "Moje talie", + "headingSelect": "Wybierz talię", + "tabDecks": "Moje talie", + "tabSubscriptions": "Subskrypcje", + "createNew": "Utwórz nową", + "refreshing": "Odświeżanie…", + "refreshAll": "Odśwież wszystkie", + "manageFeeds": "Zarządzaj kanałami", + "searchPlaceholder": "Szukaj talii…", + "formatLabel": "Format", + "browseAetherhub": "Przeglądaj talie {{format}} na Aetherhub", + "showAllDecks": "Pokaż wszystkie talie", + "showLegalOnly": "Pokaż tylko dozwolone", + "showAllDecksButton": "Pokaż wszystkie talie", + "sortName": "Nazwa", + "sortDateAdded": "Data dodania", + "sortFormat": "Format", + "ascending": "Rosnąco", + "descending": "Malejąco", + "saveAsPrompt": "Zapisz jako:", + "banner": { + "showingAllFor": "Wyświetlanie wszystkich zapisanych talii dla", + "showingLegalIn": "Wyświetlanie talii dozwolonych w", + "deckCount": "· {{visible}} z {{total}}" + }, + "status": { + "fallbackUserDeck": "talia użytkownika", + "fallbackVisibleDeck": "widoczna talia", + "startingWorker": "Uruchamianie procesu zgodności…", + "loadingDatabase": "Wczytywanie bazy danych zgodności…", + "checkingDeck": "Sprawdzanie {{deck}}…", + "checkedDeck": "Sprawdzono {{deck}}", + "loadingCoverage": "Wczytywanie obsługi dla {{deck}}…", + "checkingSelected": "Sprawdzanie zgodności wybranej talii…", + "evaluatingVisible": "Ocenianie widocznych talii…", + "remaining": "Pozostało {{count}}" + }, + "compatibilityError": "Sprawdzanie zgodności niedostępne: {{error}}", + "empty": { + "title": "Brak talii pasujących do tego filtra.", + "selectHint": "Zaimportuj zgodną talię lub zmień format, aby zobaczyć dostępne talie.", + "manageHint": "Wybierz inny filtr lub pokaż wszystkie talie, aby wybrać z całej kolekcji." + }, + "sectionMyDecks": "Moje talie", + "sectionStarterDecks": "Talie startowe", + "sectionLegalPrecons": "Dozwolone precony", + "importDeckTile": "Importuj talię", + "preconstructedTile": "Gotowa talia", + "browseAll": "Przeglądaj wszystkie", + "loadMore": "Wczytaj więcej", + "selectedDeck": "Wybrana talia", + "chooseDeckToContinue": "Wybierz talię, aby kontynuować", + "filterAll": "Wszystkie" + }, + "subscriptions": { + "emptyTitle": "Brak subskrypcji kanałów", + "emptyDescription": "Subskrybuj kanały talii, aby otrzymywać wyselekcjonowane kolekcje talii, które aktualizują się automatycznie.", + "feedMeta_one": "· {{count}} talia · Zaktualizowano {{date}}", + "feedMeta_other": "· {{count}} talii · Zaktualizowano {{date}}", + "error": "Błąd: {{error}}" + }, + "myDecksPage": { + "eyebrow": "Talie", + "title": "Talie.", + "description": "Otwórz zapisaną listę, zaimportuj nową lub kontynuuj w kreatorze talii." + }, + "loadGameState": { + "readFailed": "Nie udało się odczytać pliku" + } +} diff --git a/client/src/i18n/locales/pl/multiplayer.json b/client/src/i18n/locales/pl/multiplayer.json new file mode 100644 index 0000000000..dd1e4551ba --- /dev/null +++ b/client/src/i18n/locales/pl/multiplayer.json @@ -0,0 +1,236 @@ +{ + "concedeDialog": { + "title": "Poddać grę?", + "message": "Twój przeciwnik zostanie ogłoszony zwycięzcą.", + "concede": "Poddaj się" + }, + "connectionDot": { + "connected": "Połączono", + "connecting": "Łączenie...", + "disconnected": "Rozłączono" + }, + "connectionToast": { + "opponentDisconnected": "Przeciwnik się rozłączył", + "forfeitCountdown": "— {{seconds}}s do walkowera", + "retry": "Ponów", + "settings": "Ustawienia" + }, + "emoteOverlay": { + "ariaLabel": "Emotki", + "options": { + "goodGame": "Dobra gra", + "nicePlay": "Niezłe zagranie", + "thinking": "Myślę...", + "hello": "Cześć!", + "oops": "Ups" + } + }, + "lobbyProgress": { + "waiting": "Oczekiwanie na graczy…", + "playersReady": "{{joined}} / {{total}} graczy gotowych" + }, + "serverOfflineDialog": { + "title": "Serwer offline", + "couldNotConnect": "Nie udało się połączyć z dedykowanym serwerem rozgrywki sieciowej.", + "switchedToP2P": "Przełączono na tryb tylko P2P na tę sesję.", + "serverAddress": "Adres serwera:", + "dismiss": "Zamknij", + "openSettings": "Otwórz ustawienia" + }, + "sideboardModal": { + "game": "Gra {{number}}", + "title": "Wymiana kart bocznych", + "matchScore": "Wynik meczu {{p0}}-{{p1}}{{draws}}. Przenoś karty między talią główną a kartami bocznymi, a potem zatwierdź.", + "draws_one": " ({{count}} remis)", + "draws_other": " ({{count}} remisów)", + "main": "Główna ({{count}}/{{total}})", + "sideboard": "Karty boczne ({{count}})", + "moveFromSideboard": "Przenieś karty z kart bocznych.", + "moveFromMain": "Przenieś karty z talii głównej.", + "matchesRegistered": "Talia główna odpowiada zarejestrowanemu rozmiarowi ({{total}}).", + "submitDisabled": "Talia główna ma {{count}} / {{total}} — zatwierdzanie wyłączone.", + "resetAria": "Przywróć zarejestrowaną talię", + "reset": "Resetuj", + "submitAria": "Zatwierdź talię na następną grę", + "submitDeck": "Zatwierdź talię" + }, + "brokerOfflinePrompt": { + "title": "Serwer poczekalni nieosiągalny", + "message": "Twoja gra nie będzie publicznie wyświetlana. Nadal możesz być gospodarzem przez P2P — po prostu udostępnij kod pokoju bezpośrednio przeciwnikowi.", + "continueWithoutLobby": "Kontynuuj bez poczekalni" + }, + "joinErrorDialog": { + "dismiss": "Zamknij" + }, + "serverOfflinePrompt": { + "title": "Dobieranie graczy nieosiągalne", + "message": "Nie udało się połączyć z serwerem gry, więc publiczna poczekalnia jest teraz niedostępna. Nadal możesz grać, udostępniając bezpośredni kod znajomemu.", + "keepTrying": "Próbuj dalej", + "useDirectCode": "Użyj bezpośredniego kodu" + }, + "playerIdentityBanner": { + "playerName": "Nazwa gracza", + "namePlaceholder": "Wpisz swoją nazwę", + "editTitle": "Kliknij, aby edytować swoją nazwę", + "setName": "Ustaw nazwę…", + "change": "Zmień" + }, + "gameListItem": { + "waitTimeJustNow": "przed chwilą", + "waitTimeMinutes": "{{count}} min temu", + "waitTimeHours": "{{count}} godz. temu", + "buildMismatchTitle": "Gospodarz ma wersję {{version}} ({{commit}}) — twoja kompilacja jest inna. Odśwież, aby zaktualizować.", + "gameFull": "Ta gra jest pełna.", + "sandboxConfirm": "To jest gra w trybie piaskownicy. Gracze z uprawnieniami debugowania mogą bezpośrednio manipulować stanem gry. Kontynuować?", + "draftBadgeTitle": "Draft {{kind}} — {{setCode}}", + "draftBadge": "Draft {{setCode}}", + "p2pBadgeTitle": "Gra peer-to-peer (gospodarz uruchamia silnik)", + "sandboxBadgeTitle": "Ta gra pozwala na akcje debugowania. Używaj do testów — to nie jest mecz wyczynowy.", + "anonymous": "Anonimowy", + "by": "autor: {{name}}", + "passwordProtected": "Chronione hasłem" + }, + "serverPicker": { + "title": "Serwer", + "subtitle": "Wybierz region lub połącz się z własną instancją.", + "noneLabel": "Brak (tylko P2P)", + "directCodes": "bezpośrednie kody", + "selfHosted": "Własny hosting", + "customUrlPlaceholder": "wss://twoj-serwer.example/ws", + "test": "Testuj", + "use": "Użyj", + "urlError": "URL musi zaczynać się od ws:// lub wss://", + "connected": "Połączono", + "connectionFailed": "Połączenie nie powiodło się", + "testing": "Testowanie…" + }, + "lobbyDraftRooms": { + "noRooms": "Brak dostępnych pokoi draftu", + "playerHosted": "Hostowane przez gracza", + "server": "Serwer", + "passwordProtected": "Chronione hasłem", + "join": "Dołącz", + "createTitle": "Utwórz draft serwerowy", + "set": "Dodatek", + "kind": "Rodzaj", + "premier": "Premier", + "traditional": "Tradycyjny", + "podSize": "Rozmiar grupy", + "timerSeconds": "Czas (s)", + "passwordOptional": "Hasło (opcjonalnie)", + "passwordPlaceholder": "Pozostaw puste dla gry publicznej", + "creating": "Tworzenie...", + "createDraft": "Utwórz draft" + }, + "lobbyView": { + "directConnection": "Połączenie bezpośrednie", + "onlineLobby": "Poczekalnia online", + "pickServerTitle": "Wybierz serwer, aby korzystać z trybu poczekalni online", + "pickServer": "Wybierz serwer", + "online": "{{count}} online", + "format": "Format", + "allFormats": "Wszystkie formaty", + "roomTypeAll": "Wszystkie", + "roomTypeDraft": "Draft", + "roomTypeP2P": "P2P", + "roomTypeServer": "Serwer", + "openTables": "Otwarte stoły", + "noFormatGames": "Brak gier {{format}} w tej chwili.", + "noOpenGames": "Brak otwartych gier w tej chwili.", + "showAllFormats": "Pokaż wszystkie formaty", + "p2pNotice": "Dedykowany serwer niedostępny. Nadal możesz być gospodarzem lub dołączyć bezpośrednio za pomocą 5-znakowego kodu pokoju.", + "joinByCode": "Dołącz za pomocą kodu", + "joinATable": "Dołącz do stołu", + "p2pCodePlaceholder": "Wpisz 5-znakowy kod P2P", + "serverCodePlaceholder": "Wpisz kod lub KOD@IP:PORT", + "join": "Dołącz", + "host": "Hostuj", + "hostP2PDescription": "Utwórz bezpośredni pokój dla jednego przeciwnika.", + "hostServerDescription": "Otwórz pokój i czekaj na graczy.", + "hostDraft": "Hostuj draft", + "hostGame": "Hostuj grę", + "hostP2PGame": "Hostuj grę P2P", + "passwordRequired": "Wymagane hasło", + "passwordPlaceholder": "Wpisz hasło" + }, + "hostSetup": { + "hostDirectMatch": "Hostuj mecz bezpośredni", + "hostMatch": "Hostuj mecz", + "p2pNotice": "Dedykowany serwer jest niedostępny, więc ten pokój użyje połączenia bezpośredniego.", + "roomName": "Nazwa pokoju", + "optional": "(opcjonalnie)", + "roomNamePlaceholder": "np. Piątkowy wieczór Commander", + "roomNameDefaultPlaceholder": "Stół gracza {{name}}", + "roomNameHelp": "Wyświetlana graczom przeglądającym poczekalnię.", + "roomNameHelpDefault": " Domyślnie \"Stół gracza {{name}}\".", + "format": "Format", + "startingLife": "Początkowe życie", + "deckSize": "Rozmiar talii", + "players": "Gracze", + "matchType": "Typ meczu", + "bo1": "BO1", + "bo3": "BO3", + "bo3Note": "BO3 jest dostępne tylko dla meczów 2-osobowych.", + "commanderDamage": "Obrażenia od dowódcy", + "playerSeats": "Miejsca graczy", + "seat": "Miejsce {{number}}", + "youHost": "Ty (gospodarz)", + "ai": "SI", + "human": "Człowiek", + "waitingForPlayer": "Oczekiwanie na gracza", + "startWhenFull": "Rozpocznij po zapełnieniu", + "listInLobby": "Wyświetl w poczekalni", + "sandboxMode": "Tryb piaskownicy — zezwól na akcje debugowania", + "sandboxModeHelp": "Gospodarz może bezpośrednio manipulować stanem gry (przenosić karty, zmieniać życie, modyfikować znaczniki) i przyznawać uprawnienia debugowania innym graczom. Używaj do testów lub gry w piaskownicy — nie do meczów wyczynowych. Tego ustawienia nie można zmienić po rozpoczęciu gry.", + "setPassword": "Ustaw hasło", + "passwordPlaceholder": "Hasło do gry", + "back": "Wstecz", + "hostP2PGame": "Hostuj grę P2P", + "hostGame": "Hostuj grę" + }, + "page": { + "deckRejected": "Talia została odrzucona przez gospodarza.", + "selectDeckFirst": "Wybierz talię przed kontynuowaniem.", + "couldNotLoadDeck": "Nie udało się wczytać talii. Spróbuj zaimportować ją ponownie.", + "deckNotLegal": "Talia nie jest dozwolona w formacie {{format}}.", + "deckCheckFailed": "Sprawdzanie talii nie powiodło się: {{error}}", + "deckCheckFailedGeneric": "Sprawdzanie talii nie powiodło się.", + "failedToJoinDraft": "Nie udało się dołączyć do grupy draftu.", + "passwordPrompt": "Ten pokój wymaga hasła:", + "joinErrorOutOfDateTitle": "Klient nieaktualny", + "joinErrorRefresh": "Odśwież", + "joinErrorCantJoinTitle": "Nie można dołączyć do tego pokoju", + "titleLobby": "Dołącz do stołu lub załóż własny.", + "titleHostSetup": "Skonfiguruj swój stół.", + "titleDraftLobby": "Grupa draftu", + "titleDeckSelect": "Wybierz talię.", + "descriptionLobby": "Przeglądaj dostępne stoły, dołączaj za pomocą kodu lub załóż nowy mecz.", + "descriptionHostSetup": "Dostosuj format, prywatność i czas przed otwarciem pokoju.", + "descriptionDraftLobby": "Oczekiwanie na graczy dołączających do grupy draftu.", + "descriptionDeckSelectFormat": "Wybierz talię dla formatu {{format}}.", + "descriptionDeckSelect": "Wybierz talię, którą chcesz wystawić online.", + "eyebrow": "Rozgrywka sieciowa", + "activeDeck": "Aktywna talia", + "edit": "Edytuj", + "change": "Zmień", + "noDeckWarning": "Nie wybrano talii — musisz wybrać jedną przed hostowaniem.", + "pickDeck": "Wybierz talię", + "anonymous": "Anonimowy", + "joining": "Dołączanie" + }, + "draftLobbyPanel": { + "draftPod": "Grupa draftu", + "connecting": "Łączenie z grupą draftu...", + "connectionFailed": "Połączenie nie powiodło się.", + "playersJoined": "Dołączyło {{joined}}/{{total}} graczy", + "seat": "Miejsce {{number}}", + "draftInProgress": "Trwa draft. Widok draftu otworzy się automatycznie.", + "leaveDraft": "Opuść draft" + }, + "deckLegalityChip": { + "checking": "Sprawdzanie talii pod kątem formatu {{format}}…", + "checkingLegality": "Sprawdzanie zgodności talii z zasadami…", + "legal": "✓ Dozwolona w formacie {{format}}", + "notLegal": "Niedozwolona w formacie {{format}}" + } +} diff --git a/client/src/i18n/locales/pl/settings.json b/client/src/i18n/locales/pl/settings.json new file mode 100644 index 0000000000..80a4e5ce9f --- /dev/null +++ b/client/src/i18n/locales/pl/settings.json @@ -0,0 +1,144 @@ +{ + "modal": { + "title": "Ustawienia", + "subtitle": "Dostosuj rozgrywkę, grafikę, dźwięk i domyślne ustawienia trybu wieloosobowego." + }, + "tabs": { + "gameplay": "Rozgrywka", + "visual": "Grafika", + "combat": "Tempo", + "audio": "Dźwięk", + "multiplayer": "Tryb wieloosobowy", + "data": "Dane", + "experimental": "Eksperymentalne" + }, + "gameplay": { + "title": "Rozgrywka", + "language": "Język", + "cardSize": "Rozmiar kart", + "logDefault": "Domyślny dziennik", + "spellPayment": "Płatność za czary", + "manualManaPayment": "Ręczna płatność many za czary", + "boardBackground": "Tło pola bitwy", + "boardBackgroundGroups": { + "automatic": "Automatyczne", + "battlefields": "Pola bitwy", + "plain": "Jednolite", + "custom": "Niestandardowe", + "off": "Wyłączone" + }, + "boardBackgroundOptions": { + "autoMatchDeck": "Auto (dopasuj do talii)", + "random": "Losowe", + "customUrl": "Niestandardowy URL", + "none": "Brak" + }, + "cardSizeOptions": { + "small": "Małe", + "medium": "Średnie", + "large": "Duże" + }, + "logDefaultOptions": { + "open": "Otwarty", + "closed": "Zamknięty" + } + }, + "visual": { + "title": "Grafika", + "vfxQuality": "Jakość efektów wizualnych", + "keywordStrip": "Pasek słów kluczowych", + "showKeywords": "Pokaż słowa kluczowe na kartach na polu bitwy", + "opponentHoverPreview": "Podgląd po najechaniu na przeciwnika", + "showOpponentBoard": "Pokaż pole bitwy przeciwnika po najechaniu na HUD", + "cardArtPreferences": "Preferencje grafiki kart", + "clearArtOverrides_one": "Wyczyść wszystkie nadpisania grafiki ({{count}})", + "clearArtOverrides_other": "Wyczyść wszystkie nadpisania grafiki ({{count}})", + "clearArtOverridesConfirm_one": "Wyczyścić {{count}} nadpisanie grafiki?", + "clearArtOverridesConfirm_other": "Wyczyścić wszystkie nadpisania grafiki ({{count}})?", + "vfxQualityOptions": { + "full": "Pełna", + "reduced": "Zredukowana", + "minimal": "Minimalna" + } + }, + "audio": { + "title": "Dźwięk", + "muteAll": "Wycisz wszystko", + "muteAllAudio": "Wycisz cały dźwięk", + "globalVolume": "Głośność ogólna", + "sfxVolume": "Głośność efektów", + "musicVolume": "Głośność muzyki" + }, + "audioTheme": { + "title": "Motyw dźwiękowy", + "theme": "Motyw", + "importTheme": "Importuj motyw", + "loading": "Ładowanie...", + "import": "Importuj", + "importFailed": "Nie udało się zaimportować motywu", + "customThemes": "Niestandardowe motywy", + "remove": "Usuń" + }, + "multiplayer": { + "title": "Tryb wieloosobowy", + "displayName": "Nazwa wyświetlana", + "displayNamePlaceholder": "Wprowadź swoją nazwę", + "serverSelectionNote": "Wybór serwera został przeniesiony do poczekalni — otwórz Tryb wieloosobowy i użyj plakietki serwera (lub „Wybierz serwer” w trybie P2P), aby zmieniać regiony, skonfigurować własną instancję lub przetestować połączenie." + }, + "experimental": { + "title": "Eksperymentalne", + "description": "Te funkcje są wciąż w fazie rozwoju. Mogą być niekompletne, zawierać błędy lub zmieniać się bez ostrzeżenia. Włącz je, aby wypróbować nowości wcześniej.", + "draftExperiments": "Eksperymenty draftowe", + "enableDraftFeatures": "Włącz eksperymentalne funkcje draftu", + "draftFeaturesDescription": "Odblokowuje Cube Draft i Pod Draft. Szybki draft przeciwko AI jest zawsze dostępny." + }, + "data": { + "title": "Kopia zapasowa i przywracanie", + "description": "Eksport pakuje twoje preferencje, zaimportowane talie i subskrypcje kanałów do jednego pliku JSON. Import przywraca je na innym komputerze. Pamięci podręczne IndexedDB (pamięć kanałów, pamięć dźwięku, zapisane gry) nie są dołączane — odbudowują się automatycznie.", + "exportBackup": "Eksportuj kopię zapasową…", + "importBackup": "Importuj kopię zapasową…", + "importConfirm": "Nadpisać istniejące preferencje i talie?\n\nOK: zastąp wszystko kopią zapasową (destrukcyjne).\nAnuluj: scal — zachowaj istniejące talie, dodaj nowe z kopii zapasowej.", + "backupDownloaded": "Kopia zapasowa pobrana.", + "imported_one": "Zaimportowano {{count}} talię.", + "imported_other": "Zaimportowano {{count}} talii.", + "importedWithPreferences_one": "Zaimportowano {{count}} talię i preferencje.", + "importedWithPreferences_other": "Zaimportowano {{count}} talii i preferencje.", + "skippedMalformed_one": "Pominięto {{count}} nieprawidłowy wpis.", + "skippedMalformed_other": "Pominięto {{count}} nieprawidłowych wpisów." + }, + "resetAll": { + "confirm": "Zresetować wszystkie preferencje do wartości domyślnych? Spowoduje to wyczyszczenie każdego ustawienia w tym oknie.", + "button": "Zresetuj wszystkie preferencje" + }, + "pacing": { + "title": "Tempo", + "resetSection": "Zresetuj sekcję", + "animationSpeed": "Szybkość animacji", + "animationSpeedDescription": "Główna szybkość — wyższa jest szybsza. Skrajnie w prawo całkowicie pomija animacje.", + "hint": "Suwaki poszczególnych kategorii mnożą się na podstawie Szybkości animacji. Kliknij dwukrotnie dowolny suwak — lub dotknij obok niego — aby zresetować.", + "instant": "Natychmiast", + "slowest": "Najwolniej", + "resetSliderLabel": "Zresetuj {{label}} do wartości domyślnej", + "atDefault": "Wartość domyślna", + "resetToDefault": "Zresetuj do wartości domyślnej" + }, + "artChain": { + "emptyState": "Używanie domyślnej grafiki Scryfall. Dodaj reguły poniżej, aby dostosować.", + "moveUp": "Przesuń w górę", + "moveDown": "Przesuń w dół", + "remove": "Usuń", + "unreachable": "Reguły poniżej „{{rule}}” są nieosiągalne — zawsze pasuje.", + "addRule": "Dodaj regułę", + "setInputPlaceholder": "Kod lub nazwa zestawu…", + "addSet": "Dodaj zestaw", + "rulesPriorityNote": "Reguły są sprawdzane od góry do dołu. Wygrywa pierwsze dopasowanie. „Wydanie źródłowe” używa zestawu z paczki draftowej lub importu talii, gdy jest dostępny. Nadpisania dla poszczególnych kart (prawy przycisk myszy w kreatorze talii) zawsze mają priorytet.", + "setEntry": "Zestaw: {{name}} ({{code}})", + "rules": { + "sourcePrinting": "Wydanie źródłowe", + "newest": "Najnowsze wydanie", + "oldest": "Najstarsze wydanie", + "preferBorderless": "Preferuj bez ramki", + "preferExtended": "Preferuj grafikę rozszerzoną" + } + } +} diff --git a/client/src/i18n/locales/pt/common.json b/client/src/i18n/locales/pt/common.json index d53de92bd6..da6511555a 100644 --- a/client/src/i18n/locales/pt/common.json +++ b/client/src/i18n/locales/pt/common.json @@ -198,5 +198,11 @@ }, "turnBanner": { "turn": "Turno {{number}}" + }, + "chrome": { + "back": "Voltar", + "settings": "Configurações", + "languageSettings": "Idioma ({{lang}}) — abrir configurações", + "languageTitle": "Idioma: {{lang}}" } } diff --git a/client/src/i18n/locales/pt/draft.json b/client/src/i18n/locales/pt/draft.json index cbb1dcf879..150b600867 100644 --- a/client/src/i18n/locales/pt/draft.json +++ b/client/src/i18n/locales/pt/draft.json @@ -60,7 +60,8 @@ "botDifficulty": "Dificuldade dos Bots", "chooseSet": "Escolha uma Coleção", "noPools": "Nenhum pool de draft disponível. Execute primeiro o pipeline de dados do draft.", - "setIconAlt": "Ícone da coleção {{name}}" + "setIconAlt": "Ícone da coleção {{name}}", + "loadFailed": "Falha ao carregar as coleções" }, "pack": { "confirmPick": "Confirmar Escolha", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 47e026ba0d..334f4f14f9 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -8,6 +8,7 @@ "preview": { "holdCtrlBack": "Segure Ctrl para a face traseira", "holdCtrlFront": "Segure Ctrl para a face dianteira", + "holdCtrlFlip": "Segure Ctrl para virar", "altParsedAbilities": "Alt: habilidades processadas", "debugId": "ID: {{id}}", "engineParse": "Análise do Motor", @@ -307,7 +308,9 @@ "statHand": "Mão", "statCreatures": "Criaturas", "statLands": "Terrenos", - "statOther": "Outros" + "statOther": "Outros", + "compactHud": "HUD de oponente compacto. Reduz cada oponente a uma única linha fina (nome + vida); toque em um oponente para focar todo o campo de batalha dele.", + "expandHud": "Expandir HUD de oponente. Mostra a mão e o detalhamento do campo de batalha de cada oponente." }, "combat": { "attackAll": "Atacar Todos", @@ -380,7 +383,9 @@ "nounTarget": "alvo", "confirmTap": "Confirmar Virar ({{selected}}/{{count}})", "keepCurrentTargets": "Manter Alvos Atuais", - "skip": "Pular" + "skip": "Pular", + "chooseNewTarget": "Escolha o novo alvo da mágica", + "chooseNewTargetForSpell": "Escolha um novo alvo para {{spell}}" }, "log": { "title": "Registro do Jogo", @@ -1247,6 +1252,9 @@ "proliferateSubtitle": "Escolha qualquer número de permanentes e jogadores com marcadores. Cada alvo escolhido recebe mais um marcador de cada tipo já presente.", "chooseObjectsTitle": "Escolher Permanentes", "chooseObjectsSubtitle": "Escolha qualquer número de permanentes. Você paga um custo por cada um escolhido.", + "selectAll": "Todos", + "selectNone": "Nenhum", + "selectMine": "Meu lado", "confirm": "Confirmar" }, "replacement": { @@ -1260,7 +1268,8 @@ "scopeSingle": "Escolha um novo alvo para a mágica", "scopeMulti": "Escolha novos alvos para a mágica", "subtitle": "{{scope}}. Atual: {{current}}", - "confirm": "Confirmar" + "confirm": "Confirmar", + "badgeNewTarget": "Novo alvo" }, "separatePiles": { "chooserName": "Jogador {{number}}", @@ -1321,5 +1330,27 @@ "subtitleLabel": "Escolha um rótulo para {{name}}", "subtitleVoteRemaining": "Dê um voto ({{count}} restantes)", "subtitleVote": "Dê seu voto" + }, + "advance": { + "resolve": "Resolver", + "passPriority": "Passar prioridade", + "toPhase": "Para {{phase}}" + }, + "outsideGame": { + "fromExile": "Do exílio", + "fromSideboard": "Do sideboard" + }, + "phaseName": { + "Upkeep": "Manutenção", + "Draw": "Compra", + "PreCombatMain": "Fase principal 1", + "BeginCombat": "Início do combate", + "DeclareAttackers": "Declarar atacantes", + "DeclareBlockers": "Declarar bloqueadores", + "CombatDamage": "Dano de combate", + "EndCombat": "Fim do combate", + "PostCombatMain": "Fase principal 2", + "End": "Etapa final", + "Cleanup": "Limpeza" } } diff --git a/client/src/i18n/locales/pt/menu.json b/client/src/i18n/locales/pt/menu.json index f4f5fd4391..59c29c3219 100644 --- a/client/src/i18n/locales/pt/menu.json +++ b/client/src/i18n/locales/pt/menu.json @@ -4,7 +4,14 @@ }, "aiDifficulty": { "label": "Dificuldade da IA", - "ariaLabel": "Dificuldade da IA: {{difficulty}}" + "ariaLabel": "Dificuldade da IA: {{difficulty}}", + "levels": { + "VeryEasy": "Muito fácil", + "Easy": "Fácil", + "Medium": "Médio", + "Hard": "Difícil", + "VeryHard": "Muito difícil" + } }, "bracketFilter": { "label": "Filtro de bracket" @@ -205,7 +212,8 @@ "browseAll": "Explorar Todos", "loadMore": "Carregar Mais", "selectedDeck": "Deck selecionado", - "chooseDeckToContinue": "Escolha um deck para continuar" + "chooseDeckToContinue": "Escolha um deck para continuar", + "filterAll": "Todos" }, "subscriptions": { "emptyTitle": "Nenhuma inscrição em feed", @@ -213,5 +221,13 @@ "feedMeta_one": "· {{count}} deck · Atualizado em {{date}}", "feedMeta_other": "· {{count}} decks · Atualizado em {{date}}", "error": "Erro: {{error}}" + }, + "myDecksPage": { + "eyebrow": "Decks", + "title": "Decks.", + "description": "Abra uma lista salva, importe uma nova ou continue no construtor de decks." + }, + "loadGameState": { + "readFailed": "Falha ao ler o arquivo" } } diff --git a/client/src/i18n/locales/pt/settings.json b/client/src/i18n/locales/pt/settings.json index d7e2e9bd00..97b95bcb3f 100644 --- a/client/src/i18n/locales/pt/settings.json +++ b/client/src/i18n/locales/pt/settings.json @@ -32,6 +32,15 @@ "random": "Aleatório", "customUrl": "URL personalizada", "none": "Nenhum" + }, + "cardSizeOptions": { + "small": "Pequeno", + "medium": "Médio", + "large": "Grande" + }, + "logDefaultOptions": { + "open": "Aberto", + "closed": "Fechado" } }, "visual": { @@ -45,7 +54,12 @@ "clearArtOverrides_one": "Limpar Todas as Substituições de Arte ({{count}})", "clearArtOverrides_other": "Limpar Todas as Substituições de Arte ({{count}})", "clearArtOverridesConfirm_one": "Limpar todas as {{count}} substituição de arte?", - "clearArtOverridesConfirm_other": "Limpar todas as {{count}} substituições de arte?" + "clearArtOverridesConfirm_other": "Limpar todas as {{count}} substituições de arte?", + "vfxQualityOptions": { + "full": "Completa", + "reduced": "Reduzida", + "minimal": "Mínima" + } }, "audio": { "title": "Áudio", diff --git a/client/src/i18n/resources.test.ts b/client/src/i18n/resources.test.ts index 12873b9ad6..6bc84a208c 100644 --- a/client/src/i18n/resources.test.ts +++ b/client/src/i18n/resources.test.ts @@ -1,7 +1,53 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + import { describe, expect, it } from "vitest"; import { detectInitialLanguage, resources, SUPPORTED_LNGS } from "./resources"; +const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales"); + +/** Every `/.json` catalog on disk, as absolute paths. Reads the dir + * tree directly (not the Vite glob) so encoding checks see raw bytes. */ +function localeCatalogFiles(): string[] { + return readdirSync(LOCALES_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap((dir) => + readdirSync(join(LOCALES_DIR, dir.name)) + .filter((file) => file.endsWith(".json")) + .map((file) => join(LOCALES_DIR, dir.name, file)), + ); +} + +/** Collect every leaf key path in a namespace tree, prefixed with the namespace + * (`game.modeChoice.confirm`). Recurses into nested objects; treats strings (and + * any non-object value) as leaves. */ +function flattenLeafKeys( + tree: Record, + prefix: string, + out: Set, +): void { + for (const [key, value] of Object.entries(tree)) { + const path = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + flattenLeafKeys(value as Record, path, out); + } else { + out.add(path); + } + } +} + +/** The full namespace-prefixed leaf-key set for one locale, across every catalog + * the glob discovered for it. */ +function localeKeySet(lng: string): Set { + const keys = new Set(); + for (const [ns, tree] of Object.entries(resources[lng] ?? {})) { + flattenLeafKeys(tree as Record, ns, keys); + } + return keys; +} + // Gate test (plan §9 Phase 0 step 1): proves Vite's import.meta.glob runs under // vitest's transform pipeline AND that the reshape yields { lng: { ns: {...} } }. // Both the runtime catalogs and the "don't mock t, keep getByText" test strategy @@ -23,3 +69,58 @@ describe("i18n resources", () => { expect(SUPPORTED_LNGS as readonly string[]).toContain(detectInitialLanguage()); }); }); + +// Key-parity gate: `en` is the typing oracle, so every other shipped locale must +// carry the exact same namespace-prefixed leaf keys — no missing translations and +// no orphaned keys. A namespace-prefixed set comparison catches both a single +// dropped leaf and a wholesale missing/extra catalog file in one diff. Strict +// equality includes plural suffixes (`_one`/`_other`); the catalogs mirror en's +// structure, so a new CLDR plural category surfacing here is a deliberate review +// signal, not a false failure. +describe("i18n locale key parity", () => { + const enKeys = localeKeySet("en"); + + it("en (the oracle) has a non-empty key set", () => { + expect(enKeys.size).toBeGreaterThan(0); + }); + + for (const lng of SUPPORTED_LNGS) { + if (lng === "en") continue; + it(`${lng} has exactly the same keys as en`, () => { + const localeKeys = localeKeySet(lng); + const missing = [...enKeys].filter((k) => !localeKeys.has(k)).sort(); + const extra = [...localeKeys].filter((k) => !enKeys.has(k)).sort(); + // toEqual surfaces the offending keys directly in the failure diff. + expect({ missing, extra }).toEqual({ missing: [], extra: [] }); + }); + } +}); + +// Encoding gate: catalogs use literal UTF-8 characters (not `\uXXXX` escapes) so +// translations stay human-readable and reviewable. The cost of literals is +// encoding drift — a file saved as Latin-1, or mojibake pasted in — so enforce +// that every catalog is valid, BOM-free UTF-8. This reads raw bytes; the parsed +// `resources` glob cannot see encoding because Vite already decoded it. +describe("i18n locale file encoding", () => { + const files = localeCatalogFiles(); + + it("discovers catalog files to validate", () => { + expect(files.length).toBeGreaterThan(0); + }); + + for (const file of files) { + const rel = file.slice(LOCALES_DIR.length + 1); + it(`${rel} is valid, BOM-free UTF-8`, () => { + const bytes = readFileSync(file); + // A UTF-8 BOM (EF BB BF) is valid UTF-8 but trips some JSON tooling. + expect([...bytes.subarray(0, 3)]).not.toEqual([0xef, 0xbb, 0xbf]); + // fatal:true throws on any malformed UTF-8 byte sequence. + expect(() => + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + ).not.toThrow(); + // A baked-in replacement char (U+FFFD) signals earlier corruption. + const replacementChar = String.fromCharCode(0xfffd); + expect(new TextDecoder("utf-8").decode(bytes)).not.toContain(replacementChar); + }); + } +}); diff --git a/client/src/i18n/resources.ts b/client/src/i18n/resources.ts index d66a4e2dcd..5c46479ed1 100644 --- a/client/src/i18n/resources.ts +++ b/client/src/i18n/resources.ts @@ -9,7 +9,7 @@ const modules = import.meta.glob("./locales/*/*.json", { /** Languages the app ships chrome catalogs for. English is the typing oracle and * the `fallbackLng`; the others may lag without breaking the build. */ -export const SUPPORTED_LNGS = ["en", "es", "fr", "de", "it", "pt"] as const; +export const SUPPORTED_LNGS = ["en", "es", "fr", "de", "it", "pt", "pl"] as const; export type SupportedLng = (typeof SUPPORTED_LNGS)[number]; /** `{ en: { common: {...}, ... }, es: {...}, ... }` reshaped from the flat glob diff --git a/client/src/pages/MyDecksPage.tsx b/client/src/pages/MyDecksPage.tsx index 54547f6742..e6764a7c7e 100644 --- a/client/src/pages/MyDecksPage.tsx +++ b/client/src/pages/MyDecksPage.tsx @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; import { useAudioContext } from "../audio/useAudioContext"; @@ -10,6 +11,7 @@ import { useCardDataStore } from "../stores/cardDataStore"; export function MyDecksPage() { const navigate = useNavigate(); + const { t } = useTranslation("menu"); useAudioContext("deck_builder"); // Warm the shared card DB so deck compat/coverage scans below are instant. @@ -28,9 +30,9 @@ export function MyDecksPage() {
Date: Mon, 25 May 2026 10:31:13 -0700 Subject: [PATCH 06/22] =?UTF-8?q?feat(preview):=20flip=20Kamigawa=20cards?= =?UTF-8?q?=20180=C2=B0=20+=20proliferate=20side-select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card preview now detects flip-layout cards (Scryfall layout "flip", whose alternate half is the same image printed upside-down) and lets the user rotate the image 180° to read it: hold Ctrl on desktop, tap a Flip button on the mobile full-screen preview. Proliferate / choose-objects modal gains All / None / My side quick-select buttons so a player can pick only the permanents they control in one tap instead of deselecting every opponent target individually. --- client/src/components/card/CardPreview.tsx | 40 +++++++++++++++---- .../src/components/modal/ProliferateModal.tsx | 29 +++++++++++++- client/src/components/modal/targetRef.ts | 17 +++++++- client/src/hooks/useCardImage.ts | 11 ++++- client/src/i18n/locales/de/game.json | 1 + client/src/i18n/locales/en/game.json | 1 + client/src/i18n/locales/es/game.json | 1 + client/src/i18n/locales/fr/game.json | 1 + client/src/i18n/locales/it/game.json | 1 + client/src/i18n/locales/pl/game.json | 1 + client/src/i18n/locales/pt/game.json | 1 + client/src/services/scryfall.ts | 14 +++++++ 12 files changed, 107 insertions(+), 11 deletions(-) diff --git a/client/src/components/card/CardPreview.tsx b/client/src/components/card/CardPreview.tsx index 092707caa8..10e04d69c1 100644 --- a/client/src/components/card/CardPreview.tsx +++ b/client/src/components/card/CardPreview.tsx @@ -142,7 +142,7 @@ function CardPreviewInner({ const defaultFaceIndex = faceIndex ?? (isTransformed ? 1 : 0); // Battlefield path: route through oracle_id when the engine attached one. // Deck-builder path: `obj` is null, so we keep the name-based fallback. - const { src, isLoading, isRotated } = useCardImage(cardName, { + const { src, isLoading, isRotated, isFlip } = useCardImage(cardName, { size: "normal", faceIndex: defaultFaceIndex, isToken, @@ -180,8 +180,12 @@ function CardPreviewInner({ }; }, []); + // Kamigawa flip cards print both halves in one image, the alternate half + // rotated 180°. There's no second face to fetch, so Ctrl spins the same image + // 180° (flip180) instead of swapping faces the way DFC/MDFC do (showOtherFace). + const flip180 = !isMobile && ctrlHeld && isFlip; // On desktop, Ctrl swaps to the other face (back face normally, front face if transformed) - const showOtherFace = !isMobile && ctrlHeld && backFaceName != null; + const showOtherFace = !isMobile && ctrlHeld && backFaceName != null && !isFlip; // Fetch other face image when Ctrl is held (hook must always be called, but with empty // string when not needed so useCardImage short-circuits without a network request). // Battlefield path: the back_face's printed_ref carries the other face's @@ -345,9 +349,12 @@ function CardPreviewInner({ isLoading={activeLoading} src={activeSrc} isRotated={activeRotated} - backFaceHint={backFaceName != null && !showOtherFace - ? (isTransformed ? t("preview.holdCtrlFront") : t("preview.holdCtrlBack")) - : null} + flip180={flip180} + backFaceHint={isFlip + ? (flip180 ? null : t("preview.holdCtrlFlip")) + : backFaceName != null && !showOtherFace + ? (isTransformed ? t("preview.holdCtrlFront") : t("preview.holdCtrlBack")) + : null} altAvailable={Boolean(frontParseDetails || engineFrontFace)} debugObjectId={showDebugId && inspectedObjectId != null ? inspectedObjectId : null} /> @@ -373,7 +380,8 @@ function MobilePreviewOverlay({ sourcePrinting?: SourcePrinting; layout?: "modal" | "compact"; }) { - const { src, isRotated } = useCardImage(cardName, { + const { t } = useTranslation("game"); + const { src, isRotated, isFlip } = useCardImage(cardName, { size: "normal", faceIndex, oracleId: obj?.printed_ref?.oracle_id, @@ -381,6 +389,11 @@ function MobilePreviewOverlay({ sourcePrinting, }); + // Mobile has no Ctrl key, so a Kamigawa flip card's 180° spin is a tap toggle + // (desktop holds Ctrl). Only the full-screen modal layout can host the button — + // the compact peek dismisses on any tap via document-level capture listeners. + const [flipped, setFlipped] = useState(false); + // Compact layout: dismiss on the next tap or scroll anywhere, so no separate // dismiss gesture is needed. Listeners attach on a deferred tick so the very // tap that opened the preview doesn't immediately close it. Capture phase so @@ -449,8 +462,17 @@ function MobilePreviewOverlay({ draggable={false} className={isRotated ? "absolute left-1/2 top-1/2 h-[min(84vw,420px)] w-[min(60vw,300px)] -translate-x-1/2 -translate-y-1/2 rotate-90 object-cover" - : "max-h-[calc(100dvh-2rem)] max-w-full object-contain"} + : `max-h-[calc(100dvh-2rem)] max-w-full object-contain${isFlip ? " transition-transform duration-200" : ""}${flipped ? " rotate-180" : ""}`} /> + {isFlip && ( + + )}
)}
@@ -468,6 +490,7 @@ function CardImagePreview({ isLoading, src, isRotated, + flip180, backFaceHint, altAvailable, mobileMode, @@ -482,6 +505,7 @@ function CardImagePreview({ isLoading: boolean; src: string | null; isRotated: boolean; + flip180?: boolean; backFaceHint: string | null; altAvailable: boolean; mobileMode?: boolean; @@ -508,7 +532,7 @@ function CardImagePreview({ ? mobileMode ? "absolute left-1/2 top-1/2 h-[min(56vw,420px)] w-[min(40vw,300px)] -translate-x-1/2 -translate-y-1/2 rotate-90 object-cover" : "absolute left-1/2 top-1/2 h-[clamp(308px,36.4vw,661px)] w-[clamp(220px,26vw,472px)] max-h-[80vh] max-w-[42vw] -translate-x-1/2 -translate-y-1/2 rotate-90 object-cover" - : `${frameClass} object-cover`; + : `${frameClass} object-cover transition-transform duration-200${flip180 ? " rotate-180" : ""}`; // Use effective spell cost from engine if available (reflects alt costs, reductions), // otherwise fall back to printed mana cost. When the user holds Ctrl to view the diff --git a/client/src/components/modal/ProliferateModal.tsx b/client/src/components/modal/ProliferateModal.tsx index 1c6c9ad024..3b0983c6f2 100644 --- a/client/src/components/modal/ProliferateModal.tsx +++ b/client/src/components/modal/ProliferateModal.tsx @@ -2,11 +2,12 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useGameDispatch } from "../../hooks/useGameDispatch.ts"; +import { usePlayerId } from "../../hooks/usePlayerId.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import type { TargetRef, WaitingFor } from "../../adapter/types.ts"; import { ChoiceOverlay, ConfirmButton } from "./ChoiceOverlay.tsx"; import { gameButtonClass } from "../ui/buttonStyles.ts"; -import { targetKey, targetLabel } from "./targetRef.ts"; +import { filterTargetsByController, targetKey, targetLabel } from "./targetRef.ts"; type ProliferateChoice = Extract; type ChooseObjectsSelection = Extract< @@ -44,6 +45,7 @@ export function ProliferateModal({ const { t } = useTranslation("game"); const dispatch = useGameDispatch(); const objects = useGameStore((s) => s.gameState?.objects); + const playerId = usePlayerId(); const [selected, setSelected] = useState(data.eligible); @@ -72,6 +74,31 @@ export function ProliferateModal({ subtitle={t(`proliferate.${VARIANT_KEYS[variant].subtitle}`)} footer={} > + {data.eligible.length > 1 && ( +
+ + + +
+ )}
{data.eligible.map((target) => { const key = targetKey(target); diff --git a/client/src/components/modal/targetRef.ts b/client/src/components/modal/targetRef.ts index 6264d2db3e..a7b9d2762e 100644 --- a/client/src/components/modal/targetRef.ts +++ b/client/src/components/modal/targetRef.ts @@ -1,4 +1,4 @@ -import type { GameObject, TargetRef } from "../../adapter/types.ts"; +import type { GameObject, PlayerId, TargetRef } from "../../adapter/types.ts"; import { getPlayerDisplayName } from "../../stores/multiplayerStore.ts"; export function targetLabel( @@ -15,3 +15,18 @@ export function targetKey(target: TargetRef): string { if ("Object" in target) return `obj-${target.Object}`; return `player-${target.Player}`; } + +/** Filters targets down to those on a player's own side: permanents they control + * plus their own player target. Used by chooser quick-selects so a player can + * pick "my side" in one click instead of deselecting every opponent target. */ +export function filterTargetsByController( + targets: TargetRef[], + objects: Record | undefined, + playerId: PlayerId, +): TargetRef[] { + return targets.filter((target) => + "Object" in target + ? objects?.[String(target.Object)]?.controller === playerId + : target.Player === playerId, + ); +} diff --git a/client/src/hooks/useCardImage.ts b/client/src/hooks/useCardImage.ts index 1656a9e3d5..44e0ccd62e 100644 --- a/client/src/hooks/useCardImage.ts +++ b/client/src/hooks/useCardImage.ts @@ -7,6 +7,7 @@ import { fetchTokenImageUrl, findPrintingById, getCardPrintings, + isCardImageFlipLayoutSync, isCardImageRotatedSync, resolveFaceIndexSync, resolveOracleIdSync, @@ -53,6 +54,9 @@ interface UseCardImageResult { src: string | null; isLoading: boolean; isRotated: boolean; + /** True for Kamigawa-style flip cards (`layout: "flip"`), whose alternate half + * is the same image rotated 180°. The preview uses this to enable Ctrl-spin. */ + isFlip: boolean; } interface MemoryCacheEntry { @@ -314,6 +318,7 @@ export function useCardImage( const [src, setSrc] = useState(null); const [isRotated, setIsRotated] = useState(false); + const [isFlip, setIsFlip] = useState(false); const [isLoading, setIsLoading] = useState(true); const [, setArtCacheTick] = useState(0); @@ -382,6 +387,7 @@ export function useCardImage( if (overrideUrl) { setSrc(overrideUrl); setIsRotated(isCardImageRotatedSync(resolvedOracleId, cardName)); + setIsFlip(isCardImageFlipLayoutSync(resolvedOracleId, cardName)); setIsLoading(false); return; } @@ -389,6 +395,7 @@ export function useCardImage( if (!cardName && !oracleId) { setSrc(null); setIsRotated(false); + setIsFlip(false); setIsLoading(false); return; } @@ -418,11 +425,13 @@ export function useCardImage( if (!cancelled) { setSrc(imageAsset?.src || null); setIsRotated(imageAsset?.isRotated ?? false); + setIsFlip(isCardImageFlipLayoutSync(resolvedOracleId, cardName)); setIsLoading(false); } } catch { if (!cancelled) { setIsRotated(false); + setIsFlip(false); setIsLoading(false); } } @@ -453,5 +462,5 @@ export function useCardImage( size, ]); - return { src, isLoading, isRotated }; + return { src, isLoading, isRotated, isFlip }; } diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 41a754ba1c..a8f19634ce 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -9,6 +9,7 @@ "holdCtrlBack": "Strg halten für Rückseite", "holdCtrlFront": "Strg halten für Vorderseite", "holdCtrlFlip": "Strg halten zum Umdrehen", + "flip": "Umdrehen", "altParsedAbilities": "Alt: eingelesene Fähigkeiten", "debugId": "ID: {{id}}", "engineParse": "Engine-Parse", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index df5d5dc80d..5db3918a59 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -9,6 +9,7 @@ "holdCtrlBack": "Hold Ctrl for back face", "holdCtrlFront": "Hold Ctrl for front face", "holdCtrlFlip": "Hold Ctrl to flip", + "flip": "Flip", "altParsedAbilities": "Alt: parsed abilities", "debugId": "ID: {{id}}", "engineParse": "Engine Parse", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index 3e3a937c2c..53cdf6298e 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -9,6 +9,7 @@ "holdCtrlBack": "Mantén Ctrl para la cara trasera", "holdCtrlFront": "Mantén Ctrl para la cara frontal", "holdCtrlFlip": "Mantén Ctrl para girar", + "flip": "Girar", "altParsedAbilities": "Alt: habilidades analizadas", "debugId": "ID: {{id}}", "engineParse": "Análisis del motor", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index ef9c4c48c9..3faccc579b 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -9,6 +9,7 @@ "holdCtrlBack": "Maintenez Ctrl pour la face arrière", "holdCtrlFront": "Maintenez Ctrl pour la face avant", "holdCtrlFlip": "Maintenez Ctrl pour pivoter", + "flip": "Pivoter", "altParsedAbilities": "Alt : capacités analysées", "debugId": "ID : {{id}}", "engineParse": "Analyse du moteur", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index c85334ff83..e6d30c1468 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -9,6 +9,7 @@ "holdCtrlBack": "Tieni premuto Ctrl per il retro", "holdCtrlFront": "Tieni premuto Ctrl per il fronte", "holdCtrlFlip": "Tieni premuto Ctrl per girare", + "flip": "Gira", "altParsedAbilities": "Alt: abilità analizzate", "debugId": "ID: {{id}}", "engineParse": "Analisi del motore", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 1e30a75813..88779097e2 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -9,6 +9,7 @@ "holdCtrlBack": "Przytrzymaj Ctrl, aby zobaczyć tylną stronę", "holdCtrlFront": "Przytrzymaj Ctrl, aby zobaczyć przednią stronę", "holdCtrlFlip": "Przytrzymaj Ctrl, aby odwrócić", + "flip": "Odwróć", "altParsedAbilities": "Alt: przeanalizowane zdolności", "debugId": "ID: {{id}}", "engineParse": "Analiza silnika", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 334f4f14f9..9f8daa9d59 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -9,6 +9,7 @@ "holdCtrlBack": "Segure Ctrl para a face traseira", "holdCtrlFront": "Segure Ctrl para a face dianteira", "holdCtrlFlip": "Segure Ctrl para virar", + "flip": "Virar", "altParsedAbilities": "Alt: habilidades processadas", "debugId": "ID: {{id}}", "engineParse": "Análise do Motor", diff --git a/client/src/services/scryfall.ts b/client/src/services/scryfall.ts index a7aa942d2b..1d9a96cc78 100644 --- a/client/src/services/scryfall.ts +++ b/client/src/services/scryfall.ts @@ -163,6 +163,16 @@ export function isCardImageRotatedSync(oracleId: string, cardName: string): bool return isSidewaysLayout(entry?.layout); } +/** Kamigawa-style flip cards (Scryfall `layout: "flip"`) print both halves in a + * single image, the alternate half rotated 180°. The preview lets the user spin + * the image to read that half; this reports whether a card is that layout. */ +export function isCardImageFlipLayoutSync(oracleId: string, cardName: string): boolean { + if (!scryfallDataResolved) return false; + const entry = scryfallDataResolved[oracleId.toLowerCase()] + ?? scryfallDataResolved[normalizeCardName(cardName).toLowerCase()]; + return isFlipLayout(entry?.layout); +} + const SCRYFALL_DELAY_MS = 100; const MAX_RETRIES = 3; const BASE_BACKOFF_MS = 1000; @@ -178,6 +188,10 @@ function isSidewaysLayout(layout: string | undefined): boolean { return layout === "split"; } +function isFlipLayout(layout: string | undefined): boolean { + return layout === "flip"; +} + export interface ScryfallCard { id?: string; name: string; From cddf2b300c96aaf04cd3382aaedd73a3081dc667 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Mon, 25 May 2026 10:36:34 -0700 Subject: [PATCH 07/22] Fix draft tournament controls and AI presentation --- client/src/adapter/draft-adapter.ts | 4 +- client/src/adapter/p2p-draft-host.ts | 40 +- client/src/components/draft/BotIndicator.tsx | 21 + client/src/components/draft/DraftPodLobby.tsx | 3 + client/src/components/draft/HostControls.tsx | 118 +++- .../src/components/draft/SeatStatusRing.tsx | 3 + .../src/components/draft/StandingsTable.tsx | 47 +- client/src/i18n/locales/de/draft.json | 4 +- client/src/i18n/locales/en/draft.json | 4 +- client/src/i18n/locales/es/draft.json | 4 +- client/src/i18n/locales/fr/draft.json | 4 +- client/src/i18n/locales/it/draft.json | 4 +- client/src/i18n/locales/pl/draft.json | 4 +- client/src/i18n/locales/pt/draft.json | 4 +- client/src/pages/DraftPodPage.tsx | 19 +- crates/draft-core/src/session.rs | 515 ++++++++++++++++-- crates/draft-core/src/types.rs | 35 ++ crates/draft-core/src/view.rs | 122 ++++- 18 files changed, 835 insertions(+), 120 deletions(-) create mode 100644 client/src/components/draft/BotIndicator.tsx diff --git a/client/src/adapter/draft-adapter.ts b/client/src/adapter/draft-adapter.ts index 4c7b341215..863a1a16bc 100644 --- a/client/src/adapter/draft-adapter.ts +++ b/client/src/adapter/draft-adapter.ts @@ -299,9 +299,9 @@ export class DraftAdapter { ); } - async replaceSeatWithBot(seat: number): Promise { + async replaceSeatWithBot(seat: number, name?: string): Promise { return this.applyActionAndGetHostView( - JSON.stringify({ type: "ReplaceSeatWithBot", data: { seat } }), + JSON.stringify({ type: "ReplaceSeatWithBot", data: { seat, name } }), ); } } diff --git a/client/src/adapter/p2p-draft-host.ts b/client/src/adapter/p2p-draft-host.ts index 4e9ef2a256..0d234cd559 100644 --- a/client/src/adapter/p2p-draft-host.ts +++ b/client/src/adapter/p2p-draft-host.ts @@ -115,6 +115,14 @@ function deckPayload(mainDeck: string[], sideboard: string[]): DraftDeckPayload return { main_deck: mainDeck, sideboard, commander: [] }; } +function hashStringToSeed(value: string): number { + let hash = 5381; + for (let i = 0; i < value.length; i++) { + hash = ((hash * 33) ^ value.charCodeAt(i)) | 0; + } + return hash >>> 0; +} + function sideboardFromPool( session: ExportedDraftSession, seat: number, @@ -153,6 +161,7 @@ export class P2PDraftHost { private draftStarted = false; private draftCode = ""; + private draftSeed: number | null = null; private activePodSize: number; private hostConnectionUnsub: (() => void) | null = null; private paused = false; @@ -429,6 +438,7 @@ export class P2PDraftHost { if (this.draftStarted) return; const seed = Math.floor(Math.random() * 0xffffffff); + this.draftSeed = seed; const draftCode = `draft-${seed.toString(16).padStart(8, "0")}`; const seats: MultiplayerSeatDescriptor[] = []; for (let i = 0; i < this.podSize; i++) { @@ -784,7 +794,7 @@ export class P2PDraftHost { } private botNameForSeat(seat: number, seed: number): string { - return assignAvatarForSeat(this.podSize, seat, seed)?.name ?? `AI player ${seat + 1}`; + return assignAvatarForSeat(this.podSize, seat, seed)?.name ?? `Seat ${seat + 1}`; } // ── Match coordination ──────────────────────────────────────────────── @@ -796,12 +806,32 @@ export class P2PDraftHost { async generatePairings(round: number): Promise { try { const view = await this.adapter.generatePairings(round); + const launchablePairings = view.pairings.filter((pairing) => + pairing.round === round && + (pairing.status === "Pending" || pairing.status === "InProgress") + ); - for (const pairing of view.pairings) { + for (const pairing of launchablePairings) { + if ( + this.isBotSeatFromView(view, pairing.seat_a) && + this.isBotSeatFromView(view, pairing.seat_b) + ) { + await this.dispatchMatchLaunch(pairing, view); + } + } + + const postBotView = await this.adapter.getViewForSeat(0); + for (const pairing of postBotView.pairings) { if (pairing.round !== round) continue; if (pairing.status !== "Pending" && pairing.status !== "InProgress") continue; + if ( + this.isBotSeatFromView(postBotView, pairing.seat_a) && + this.isBotSeatFromView(postBotView, pairing.seat_b) + ) { + continue; + } - await this.dispatchMatchLaunch(pairing, view); + await this.dispatchMatchLaunch(pairing, postBotView); } const latestView = await this.adapter.getViewForSeat(0); @@ -1000,7 +1030,8 @@ export class P2PDraftHost { */ async replaceSeatWithBot(seat: number): Promise { try { - await this.adapter.replaceSeatWithBot(seat); + const seed = this.draftSeed ?? hashStringToSeed(this.draftCode || this.roomCode || "draft"); + await this.adapter.replaceSeatWithBot(seat, this.botNameForSeat(seat, seed)); await this.broadcastViews(); this.persistSession(); } catch (err) { @@ -1339,6 +1370,7 @@ export class P2PDraftHost { } this.draftStarted = session.draftStarted; this.draftCode = session.draftCode; + this.draftSeed = hashStringToSeed(session.draftCode || this.roomCode || "draft"); if (session.draftSessionJson) { const view = await this.adapter.importSession(session.draftSessionJson, 2); diff --git a/client/src/components/draft/BotIndicator.tsx b/client/src/components/draft/BotIndicator.tsx new file mode 100644 index 0000000000..c05dd8bdc6 --- /dev/null +++ b/client/src/components/draft/BotIndicator.tsx @@ -0,0 +1,21 @@ +interface BotIndicatorProps { + label: string; + size?: "sm" | "md"; +} + +export function BotIndicator({ label, size = "md" }: BotIndicatorProps) { + const boxSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4"; + return ( + + + + + + + + ); +} diff --git a/client/src/components/draft/DraftPodLobby.tsx b/client/src/components/draft/DraftPodLobby.tsx index 7d43a13f22..2f49b49fa7 100644 --- a/client/src/components/draft/DraftPodLobby.tsx +++ b/client/src/components/draft/DraftPodLobby.tsx @@ -23,6 +23,7 @@ import type { SeatPublicView } from "../../adapter/draft-adapter"; import { menuButtonClass } from "../menu/buttonStyles"; import { useMultiplayerDraftStore } from "../../stores/multiplayerDraftStore"; import { useDraftPodStore } from "../../stores/draftPodStore"; +import { BotIndicator } from "./BotIndicator"; // ── Seat Card ───────────────────────────────────────────────────────── @@ -50,6 +51,7 @@ function SeatCard({ ? t("lobby.botSeat") : t("lobby.waitingSeat") : seat.display_name; + const botLabel = t("lobby.botSeat"); const borderColor = isLocalSeat ? "border-emerald-400/40" @@ -96,6 +98,7 @@ function SeatCard({ > {seatLabel} + {seat.is_bot && }
{/* Role badge */} diff --git a/client/src/components/draft/HostControls.tsx b/client/src/components/draft/HostControls.tsx index 7d445f5e2d..2d02357f27 100644 --- a/client/src/components/draft/HostControls.tsx +++ b/client/src/components/draft/HostControls.tsx @@ -1,10 +1,21 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router"; import { useMultiplayerDraftStore } from "../../stores/multiplayerDraftStore"; +import { useDraftPodStore } from "../../stores/draftPodStore"; import { menuButtonClass } from "../menu/buttonStyles"; const EMPTY_SEATS: Array<{ seat_index: number; display_name: string; is_bot: boolean; connected: boolean }> = []; +function winnerChoiceClass(selected: boolean): string { + return menuButtonClass({ + tone: selected ? "emerald" : "neutral", + size: "xs", + className: "min-w-0 flex-1 justify-center px-2", + }); +} + // ── Component ─────────────────────────────────────────────────────────── /** @@ -13,6 +24,8 @@ const EMPTY_SEATS: Array<{ seat_index: number; display_name: string; is_bot: boo */ export function HostControls() { const { t } = useTranslation("draft"); + const navigate = useNavigate(); + const [endingDraft, setEndingDraft] = useState(false); const role = useMultiplayerDraftStore((s) => s.role); const phase = useMultiplayerDraftStore((s) => s.phase); const podPolicy = useMultiplayerDraftStore((s) => s.view?.pod_policy); @@ -24,6 +37,8 @@ export function HostControls() { const overrideMatchResult = useMultiplayerDraftStore( (s) => s.overrideMatchResult, ); + const leave = useMultiplayerDraftStore((s) => s.leave); + const resetPod = useDraftPodStore((s) => s.reset); const replaceSeatWithBot = useMultiplayerDraftStore( (s) => s.replaceSeatWithBot, ); @@ -37,18 +52,42 @@ export function HostControls() { podPolicy === "Casual" && phase === "roundComplete"; const showOverride = podPolicy === "Casual" && - phase === "matchInProgress" && + (phase === "matchInProgress" || phase === "roundComplete") && pairings.length > 0; const humanSeats = seats.filter((s) => !s.is_bot); const showKickReplace = humanSeats.length > 0 && (phase === "matchInProgress" || phase === "roundComplete"); + const showEndDraft = ![ + "idle", + "connecting", + "complete", + "error", + "kicked", + "hostLeft", + ].includes(phase); + + const handleEndDraft = async () => { + if (endingDraft) return; + if (!window.confirm(t("hostControls.endDraftConfirm"))) return; + + setEndingDraft(true); + try { + await leave(false); + resetPod(); + navigate("/"); + } catch (err) { + console.error("[HostControls] failed to end draft:", err); + setEndingDraft(false); + } + }; if ( !showPauseResume && !showAdvanceRound && !showOverride && - !showKickReplace + !showKickReplace && + !showEndDraft ) return null; @@ -83,32 +122,46 @@ export function HostControls() { {/* Override match result — Casual mode, during matches */} {showOverride && ( -
+
{t("hostControls.overrideResult")}
- {pairings - .filter((p) => p.status !== "Complete") - .map((p) => ( -
- - {t("hostControls.versusPair", { a: p.name_a, b: p.name_b })} - - - + + {t("standings.versus")} + + +
+
- {p.name_b.split(" ")[0]} - + {p.match_id} - {p.status} +
- ))} + ); + })}
)} @@ -127,6 +180,21 @@ export function HostControls() { ))}
)} + + {showEndDraft && ( + + )} ); } diff --git a/client/src/components/draft/SeatStatusRing.tsx b/client/src/components/draft/SeatStatusRing.tsx index cbde788aee..f46fe3e808 100644 --- a/client/src/components/draft/SeatStatusRing.tsx +++ b/client/src/components/draft/SeatStatusRing.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import type { SeatPublicView } from "../../adapter/draft-adapter"; import { useMultiplayerDraftStore } from "../../stores/multiplayerDraftStore"; +import { BotIndicator } from "./BotIndicator"; const EMPTY_SEATS: SeatPublicView[] = []; @@ -30,6 +31,7 @@ interface SeatBadgeProps { function SeatBadge({ seat, isLocal }: SeatBadgeProps) { const { t } = useTranslation("draft"); + const botLabel = t("lobby.botSeat"); const borderColor = isLocal ? "border-emerald-400/40" : PICK_STATUS_BORDER[seat.pick_status]; @@ -42,6 +44,7 @@ function SeatBadge({ seat, isLocal }: SeatBadgeProps) { {seat.display_name || t("seat.label", { number: seat.seat_index + 1 })} + {seat.is_bot && } ); } diff --git a/client/src/components/draft/StandingsTable.tsx b/client/src/components/draft/StandingsTable.tsx index 9feb8ea351..ae904734ad 100644 --- a/client/src/components/draft/StandingsTable.tsx +++ b/client/src/components/draft/StandingsTable.tsx @@ -20,6 +20,40 @@ function formatGwp(entry: StandingEntry): string { return `${(computeGwp(entry) * 100).toFixed(0)}%`; } +function podiumRowClass(rank: number): string { + switch (rank) { + case 0: + return "bg-amber-400/[0.08] text-amber-50"; + case 1: + return "bg-slate-200/[0.07] text-slate-50"; + case 2: + return "bg-orange-400/[0.07] text-orange-50"; + default: + return ""; + } +} + +function podiumBadgeClass(rank: number): string { + const base = + "inline-flex h-6 min-w-6 items-center justify-center rounded-full border px-1.5 text-xs font-semibold tabular-nums"; + switch (rank) { + case 0: + return `${base} border-amber-300/40 bg-amber-300/18 text-amber-100`; + case 1: + return `${base} border-slate-200/40 bg-slate-200/14 text-slate-100`; + case 2: + return `${base} border-orange-300/40 bg-orange-300/16 text-orange-100`; + default: + return `${base} border-white/10 bg-white/[0.04] text-white/45`; + } +} + +function localPlayerRowClass(isLocal: boolean): string { + return isLocal + ? "bg-emerald-400/[0.08] [box-shadow:inset_3px_0_0_rgba(52,211,153,0.75)]" + : ""; +} + // ── Component ─────────────────────────────────────────────────────────── /** Swiss tournament standings sorted by match wins (GWP tiebreaker), with current round pairings and live game scores. */ @@ -59,11 +93,16 @@ export function StandingsTable() { {sorted.map((entry, i) => ( - {i + 1} + + {i + 1} + {entry.display_name} {entry.match_wins}-{entry.match_losses} diff --git a/client/src/i18n/locales/de/draft.json b/client/src/i18n/locales/de/draft.json index 3b6f819b60..269eb69f97 100644 --- a/client/src/i18n/locales/de/draft.json +++ b/client/src/i18n/locales/de/draft.json @@ -39,7 +39,9 @@ "overrideResult": "Ergebnis überschreiben", "versusPair": "{{a}} gegen {{b}}", "kickReplace": "Rauswerfen + Ersetzen", - "replaceWithBot": "{{name}} durch Bot ersetzen" + "replaceWithBot": "{{name}} durch Bot ersetzen", + "endDraft": "Draft beenden", + "endDraftConfirm": "Diesen Draft fuer alle beenden? Das kann nicht rueckgaengig gemacht werden." }, "manaCurve": { "title": "Manakurve" diff --git a/client/src/i18n/locales/en/draft.json b/client/src/i18n/locales/en/draft.json index 0c294d23b4..3dc186512c 100644 --- a/client/src/i18n/locales/en/draft.json +++ b/client/src/i18n/locales/en/draft.json @@ -39,7 +39,9 @@ "overrideResult": "Override Result", "versusPair": "{{a}} v {{b}}", "kickReplace": "Kick + Replace", - "replaceWithBot": "Replace {{name}} with Bot" + "replaceWithBot": "Replace {{name}} with Bot", + "endDraft": "End Draft", + "endDraftConfirm": "End this draft for everyone? This cannot be undone." }, "manaCurve": { "title": "Mana Curve" diff --git a/client/src/i18n/locales/es/draft.json b/client/src/i18n/locales/es/draft.json index f143523970..4a43163a6c 100644 --- a/client/src/i18n/locales/es/draft.json +++ b/client/src/i18n/locales/es/draft.json @@ -39,7 +39,9 @@ "overrideResult": "Anular resultado", "versusPair": "{{a}} contra {{b}}", "kickReplace": "Expulsar + reemplazar", - "replaceWithBot": "Reemplazar a {{name}} con un bot" + "replaceWithBot": "Reemplazar a {{name}} con un bot", + "endDraft": "Terminar draft", + "endDraftConfirm": "¿Terminar este draft para todos? Esta acción no se puede deshacer." }, "manaCurve": { "title": "Curva de maná" diff --git a/client/src/i18n/locales/fr/draft.json b/client/src/i18n/locales/fr/draft.json index 8063065897..7662ad16a1 100644 --- a/client/src/i18n/locales/fr/draft.json +++ b/client/src/i18n/locales/fr/draft.json @@ -39,7 +39,9 @@ "overrideResult": "Modifier le résultat", "versusPair": "{{a}} c. {{b}}", "kickReplace": "Exclure + Remplacer", - "replaceWithBot": "Remplacer {{name}} par un bot" + "replaceWithBot": "Remplacer {{name}} par un bot", + "endDraft": "Terminer le draft", + "endDraftConfirm": "Terminer ce draft pour tout le monde ? Cette action est irreversible." }, "manaCurve": { "title": "Courbe de mana" diff --git a/client/src/i18n/locales/it/draft.json b/client/src/i18n/locales/it/draft.json index 4847af80f7..4975cdabd0 100644 --- a/client/src/i18n/locales/it/draft.json +++ b/client/src/i18n/locales/it/draft.json @@ -39,7 +39,9 @@ "overrideResult": "Sovrascrivi risultato", "versusPair": "{{a}} v {{b}}", "kickReplace": "Espelli + sostituisci", - "replaceWithBot": "Sostituisci {{name}} con un bot" + "replaceWithBot": "Sostituisci {{name}} con un bot", + "endDraft": "Termina draft", + "endDraftConfirm": "Terminare questo draft per tutti? L'azione non puo essere annullata." }, "manaCurve": { "title": "Curva del mana" diff --git a/client/src/i18n/locales/pl/draft.json b/client/src/i18n/locales/pl/draft.json index d30897dbba..d3d53e0fa0 100644 --- a/client/src/i18n/locales/pl/draft.json +++ b/client/src/i18n/locales/pl/draft.json @@ -39,7 +39,9 @@ "overrideResult": "Zmień wynik", "versusPair": "{{a}} v {{b}}", "kickReplace": "Wyrzuć i zastąp", - "replaceWithBot": "Zastąp gracza {{name}} botem" + "replaceWithBot": "Zastąp gracza {{name}} botem", + "endDraft": "Zakoncz draft", + "endDraftConfirm": "Zakonczyc ten draft dla wszystkich? Tej akcji nie mozna cofnac." }, "manaCurve": { "title": "Krzywa many" diff --git a/client/src/i18n/locales/pt/draft.json b/client/src/i18n/locales/pt/draft.json index 150b600867..99038d5460 100644 --- a/client/src/i18n/locales/pt/draft.json +++ b/client/src/i18n/locales/pt/draft.json @@ -39,7 +39,9 @@ "overrideResult": "Substituir Resultado", "versusPair": "{{a}} v {{b}}", "kickReplace": "Expulsar + Substituir", - "replaceWithBot": "Substituir {{name}} por Bot" + "replaceWithBot": "Substituir {{name}} por Bot", + "endDraft": "Encerrar Draft", + "endDraftConfirm": "Encerrar este draft para todos? Isso nao pode ser desfeito." }, "manaCurve": { "title": "Curva de Mana" diff --git a/client/src/pages/DraftPodPage.tsx b/client/src/pages/DraftPodPage.tsx index a744766b86..c8cdd17add 100644 --- a/client/src/pages/DraftPodPage.tsx +++ b/client/src/pages/DraftPodPage.tsx @@ -374,6 +374,7 @@ function MatchInProgressView() { ? matchPairing.botName : matchPairing.opponentName : null; + const isBotMatch = matchPairing?.type === "Bot"; const isHost = matchPairing?.type === "HumanHost"; return ( @@ -387,18 +388,24 @@ function MatchInProgressView() {
{t("podPhaseView.versusOpponent", { name: opponentName })}
-
- {isHost - ? t("podPhaseView.youAreHosting") - : t("podPhaseView.connectingOpponent")} -
+ {!isBotMatch && ( +
+ {isHost + ? t("podPhaseView.youAreHosting") + : t("podPhaseView.connectingOpponent")} +
+ )} diff --git a/crates/draft-core/src/session.rs b/crates/draft-core/src/session.rs index 13abd044a4..e5d6f6b71e 100644 --- a/crates/draft-core/src/session.rs +++ b/crates/draft-core/src/session.rs @@ -64,7 +64,9 @@ pub fn apply( winner_seat, } => apply_report_match_result(session, match_id, winner_seat), DraftAction::AdvanceRound => apply_advance_round(session), - DraftAction::ReplaceSeatWithBot { seat } => apply_replace_seat_with_bot(session, seat), + DraftAction::ReplaceSeatWithBot { seat, name } => { + apply_replace_seat_with_bot(session, seat, name) + } } } @@ -229,6 +231,7 @@ fn generate_swiss_pairings( players: [*p1, *p2], match_id: format!("r{round}-t{table}"), status: PairingStatus::Pending, + winner: None, }) .collect() } @@ -249,6 +252,7 @@ fn generate_se_pairings(session: &DraftSession, round: u8) -> Vec players: [p1, p2], match_id: format!("r{round}-t{table}"), status: PairingStatus::Pending, + winner: None, } }) .collect() @@ -261,25 +265,9 @@ fn generate_se_pairings(session: &DraftSession, round: u8) -> Vec .filter(|p| p.round == prev_round && p.status == PairingStatus::Complete) .collect(); - // Winners: for each completed pairing, the winner is the player with more match_wins - // We determine winners from match records let winners: Vec = prev_pairings .iter() - .map(|p| { - let r0 = session.match_records.get(&p.players[0]); - let r1 = session.match_records.get(&p.players[1]); - let w0 = r0.map_or(0, |r| r.match_wins); - let w1 = r1.map_or(0, |r| r.match_wins); - // In SE, the match result determines the winner. We look at which player - // gained a match_win most recently. Since match records are cumulative, - // we check: the player with more match_wins is the winner. If tied, - // player[0] advances (shouldn't happen with proper result reporting). - if w0 >= w1 { - p.players[0] - } else { - p.players[1] - } - }) + .filter_map(|p| p.result_winner(&session.match_records)) .collect(); // Pair adjacent winners @@ -294,6 +282,7 @@ fn generate_se_pairings(session: &DraftSession, round: u8) -> Vec players: [chunk[0], chunk[1]], match_id: format!("r{round}-t{table}"), status: PairingStatus::Pending, + winner: None, }) } else { None @@ -303,12 +292,71 @@ fn generate_se_pairings(session: &DraftSession, round: u8) -> Vec } } +fn apply_match_record_result( + records: &mut HashMap, + players: [PlayerId; 2], + winner: Option, +) { + match winner { + Some(winner_pid) => { + let loser_pid = if players[0] == winner_pid { + players[1] + } else { + players[0] + }; + ensure_match_record(records, winner_pid).match_wins += 1; + ensure_match_record(records, winner_pid).wins += 1; + ensure_match_record(records, loser_pid).match_losses += 1; + ensure_match_record(records, loser_pid).losses += 1; + } + None => { + for pid in players { + ensure_match_record(records, pid).draws += 1; + } + } + } +} + +fn undo_match_record_result( + records: &mut HashMap, + players: [PlayerId; 2], + winner: Option, +) { + match winner { + Some(winner_pid) => { + let loser_pid = if players[0] == winner_pid { + players[1] + } else { + players[0] + }; + if let Some(record) = records.get_mut(&winner_pid) { + record.match_wins = record.match_wins.saturating_sub(1); + record.wins = record.wins.saturating_sub(1); + } + if let Some(record) = records.get_mut(&loser_pid) { + record.match_losses = record.match_losses.saturating_sub(1); + record.losses = record.losses.saturating_sub(1); + } + } + None => { + for pid in players { + if let Some(record) = records.get_mut(&pid) { + record.draws = record.draws.saturating_sub(1); + } + } + } + } +} + fn apply_report_match_result( session: &mut DraftSession, match_id: String, winner_seat: Option, ) -> Result, DraftError> { - if session.status != DraftStatus::MatchInProgress { + if !matches!( + session.status, + DraftStatus::MatchInProgress | DraftStatus::RoundComplete + ) { return Err(DraftError::InvalidTransition { from: session.status, action: "ReportMatchResult".to_string(), @@ -324,29 +372,52 @@ fn apply_report_match_result( match_id: match_id.clone(), })?; - session.pairings[pairing_idx].status = PairingStatus::Complete; - let players = session.pairings[pairing_idx].players; + let pairing_round = session.pairings[pairing_idx].round; + if pairing_round != session.current_round { + return Err(DraftError::PairingNotInCurrentRound { + match_id, + current_round: session.current_round, + }); + } - // Update match records - match winner_seat { + if session.config.tournament_format == TournamentFormat::SingleElimination + && winner_seat.is_none() + { + return Err(DraftError::MatchWinnerRequired { match_id }); + } + + let players = session.pairings[pairing_idx].players; + let previous_status = session.pairings[pairing_idx].status; + let previous_winner = session.pairings[pairing_idx].result_winner(&session.match_records); + let winner_pid = match winner_seat { Some(winner) => { - let winner_pid = seat_player_id(session, winner); - let loser_pid = if players[0] == winner_pid { - players[1] - } else { - players[0] - }; - ensure_match_record(&mut session.match_records, winner_pid).match_wins += 1; - ensure_match_record(&mut session.match_records, loser_pid).match_losses += 1; - } - None => { - // Draw - for &pid in &players { - ensure_match_record(&mut session.match_records, pid).draws += 1; + let pod_size = session.seats.len() as u8; + if winner >= pod_size { + return Err(DraftError::SeatOutOfRange { + seat: winner, + pod_size, + }); + } + let pid = seat_player_id(session, winner); + if !players.contains(&pid) { + return Err(DraftError::SeatNotInPairing { + seat: winner, + match_id, + }); } + Some(pid) } + None => None, + }; + + if previous_status == PairingStatus::Complete { + undo_match_record_result(&mut session.match_records, players, previous_winner); } + session.pairings[pairing_idx].status = PairingStatus::Complete; + session.pairings[pairing_idx].winner = winner_pid; + apply_match_record_result(&mut session.match_records, players, winner_pid); + let mut deltas = vec![DraftDelta::MatchResultRecorded { match_id, winner_seat, @@ -408,19 +479,15 @@ fn apply_advance_round(session: &mut DraftSession) -> Result, Dr fn apply_replace_seat_with_bot( session: &mut DraftSession, seat: u8, + name: Option, ) -> Result, DraftError> { let pod_size = session.seats.len() as u8; if seat >= pod_size { return Err(DraftError::SeatOutOfRange { seat, pod_size }); } - let old_name = match &session.seats[seat as usize] { - DraftSeat::Human { display_name, .. } => display_name.clone(), - DraftSeat::Bot { name } => name.clone(), - }; - session.seats[seat as usize] = DraftSeat::Bot { - name: format!("Bot (was {old_name})"), + name: name.unwrap_or_else(|| format!("Seat {}", seat + 1)), }; Ok(vec![DraftDelta::SeatReplacedWithBot { seat }]) @@ -959,6 +1026,7 @@ mod tests { { pairing.status = PairingStatus::Complete; let winner = pairing.players[i % 2]; + pairing.winner = Some(winner); ensure_match_record(&mut session.match_records, winner).match_wins += 1; let loser = pairing.players[(i + 1) % 2]; ensure_match_record(&mut session.match_records, loser).match_losses += 1; @@ -1041,6 +1109,97 @@ mod tests { assert_eq!(pairings[3].players, [PlayerId(3), PlayerId(4)]); } + #[test] + fn single_elimination_advances_pairing_winners() { + let config = DraftConfig { + source: DraftSource::Set { + code: "TST".to_string(), + }, + set_code: "TST".to_string(), + kind: DraftKind::Premier, + pod_size: 8, + cards_per_pack: 14, + pack_count: 3, + min_deck_size: 40, + addable_cards: DeckAddableCards::standard_basics(), + rng_seed: 42, + tournament_format: TournamentFormat::SingleElimination, + pod_policy: PodPolicy::Competitive, + spectator_visibility: SpectatorVisibility::default(), + }; + let seats: Vec = (0..8) + .map(|i| DraftSeat::Human { + player_id: PlayerId(i), + display_name: format!("Player {i}"), + connected: true, + }) + .collect(); + let mut session = DraftSession::new(config, seats, "SE-TEST".to_string()); + session.status = DraftStatus::Deckbuilding; + + apply( + &mut session, + DraftAction::GeneratePairings { round: 1 }, + None, + ) + .unwrap(); + + for (match_id, winner_seat) in [("r1-t0", 7), ("r1-t1", 6), ("r1-t2", 2), ("r1-t3", 4)] { + apply( + &mut session, + DraftAction::ReportMatchResult { + match_id: match_id.to_string(), + winner_seat: Some(winner_seat), + }, + None, + ) + .unwrap(); + } + + assert_eq!(session.status, DraftStatus::RoundComplete); + + apply(&mut session, DraftAction::AdvanceRound, None).unwrap(); + apply( + &mut session, + DraftAction::GeneratePairings { round: 2 }, + None, + ) + .unwrap(); + + let pairings: Vec<_> = session.pairings.iter().filter(|p| p.round == 2).collect(); + assert_eq!(pairings.len(), 2); + assert_eq!(pairings[0].players, [PlayerId(7), PlayerId(6)]); + assert_eq!(pairings[1].players, [PlayerId(2), PlayerId(4)]); + } + + #[test] + fn single_elimination_rejects_match_without_winner() { + let (mut session, _) = test_session(8); + session.status = DraftStatus::Deckbuilding; + session.config.tournament_format = TournamentFormat::SingleElimination; + + apply( + &mut session, + DraftAction::GeneratePairings { round: 1 }, + None, + ) + .unwrap(); + + let result = apply( + &mut session, + DraftAction::ReportMatchResult { + match_id: "r1-t0".to_string(), + winner_seat: None, + }, + None, + ); + + assert!(matches!( + result, + Err(DraftError::MatchWinnerRequired { .. }) + )); + } + #[test] fn test_report_result_updates_records() { let (mut session, _) = test_session(8); @@ -1053,33 +1212,270 @@ mod tests { ) .unwrap(); - // Report seat 0 wins match r1-t0 + let pairing = session + .pairings + .iter() + .find(|p| p.match_id == "r1-t0") + .unwrap() + .clone(); + let winner_pid = pairing.players[0]; + apply( &mut session, DraftAction::ReportMatchResult { match_id: "r1-t0".to_string(), - winner_seat: Some(0), + winner_seat: Some(winner_pid.0), }, None, ) .unwrap(); - let winner_record = session.match_records.get(&PlayerId(0)).unwrap(); + let winner_record = session.match_records.get(&winner_pid).unwrap(); assert_eq!(winner_record.match_wins, 1); + assert_eq!(winner_record.wins, 1); - // Find the loser (the other player in the pairing) let pairing = session .pairings .iter() .find(|p| p.match_id == "r1-t0") .unwrap(); - let loser_pid = if pairing.players[0] == PlayerId(0) { + assert_eq!(pairing.winner, Some(winner_pid)); + let loser_pid = if pairing.players[0] == winner_pid { pairing.players[1] } else { pairing.players[0] }; let loser_record = session.match_records.get(&loser_pid).unwrap(); assert_eq!(loser_record.match_losses, 1); + assert_eq!(loser_record.losses, 1); + } + + #[test] + fn report_match_result_replaces_previous_result() { + let (mut session, _) = test_session(8); + session.status = DraftStatus::Deckbuilding; + + apply( + &mut session, + DraftAction::GeneratePairings { round: 1 }, + None, + ) + .unwrap(); + + let pairing = session + .pairings + .iter() + .find(|p| p.match_id == "r1-t0") + .unwrap() + .clone(); + let first_winner = pairing.players[0]; + let second_winner = pairing.players[1]; + + apply( + &mut session, + DraftAction::ReportMatchResult { + match_id: pairing.match_id.clone(), + winner_seat: Some(first_winner.0), + }, + None, + ) + .unwrap(); + apply( + &mut session, + DraftAction::ReportMatchResult { + match_id: pairing.match_id.clone(), + winner_seat: Some(second_winner.0), + }, + None, + ) + .unwrap(); + + let first_record = session.match_records.get(&first_winner).unwrap(); + assert_eq!(first_record.match_wins, 0); + assert_eq!(first_record.match_losses, 1); + assert_eq!(first_record.wins, 0); + assert_eq!(first_record.losses, 1); + + let second_record = session.match_records.get(&second_winner).unwrap(); + assert_eq!(second_record.match_wins, 1); + assert_eq!(second_record.match_losses, 0); + assert_eq!(second_record.wins, 1); + assert_eq!(second_record.losses, 0); + + let updated_pairing = session + .pairings + .iter() + .find(|p| p.match_id == pairing.match_id) + .unwrap(); + assert_eq!(updated_pairing.winner, Some(second_winner)); + } + + #[test] + fn report_match_result_replaces_legacy_completed_result() { + let (mut session, _) = test_session(8); + session.status = DraftStatus::Deckbuilding; + + apply( + &mut session, + DraftAction::GeneratePairings { round: 1 }, + None, + ) + .unwrap(); + + let pairing = session + .pairings + .iter() + .find(|p| p.match_id == "r1-t0") + .unwrap() + .clone(); + let first_winner = pairing.players[0]; + let second_winner = pairing.players[1]; + + session + .pairings + .iter_mut() + .find(|p| p.match_id == pairing.match_id) + .unwrap() + .status = PairingStatus::Complete; + ensure_match_record(&mut session.match_records, first_winner).match_wins = 1; + ensure_match_record(&mut session.match_records, first_winner).wins = 1; + ensure_match_record(&mut session.match_records, second_winner).match_losses = 1; + ensure_match_record(&mut session.match_records, second_winner).losses = 1; + + apply( + &mut session, + DraftAction::ReportMatchResult { + match_id: pairing.match_id.clone(), + winner_seat: Some(second_winner.0), + }, + None, + ) + .unwrap(); + + let first_record = session.match_records.get(&first_winner).unwrap(); + assert_eq!(first_record.match_wins, 0); + assert_eq!(first_record.wins, 0); + assert_eq!(first_record.match_losses, 1); + assert_eq!(first_record.losses, 1); + + let second_record = session.match_records.get(&second_winner).unwrap(); + assert_eq!(second_record.match_wins, 1); + assert_eq!(second_record.wins, 1); + assert_eq!(second_record.match_losses, 0); + assert_eq!(second_record.losses, 0); + } + + #[test] + fn report_match_result_can_override_after_round_complete() { + let (mut session, _) = test_session(8); + session.status = DraftStatus::Deckbuilding; + + apply( + &mut session, + DraftAction::GeneratePairings { round: 1 }, + None, + ) + .unwrap(); + + let results: Vec<(String, u8)> = session + .pairings + .iter() + .filter(|p| p.round == 1) + .map(|p| (p.match_id.clone(), p.players[0].0)) + .collect(); + + for (match_id, winner_seat) in results { + apply( + &mut session, + DraftAction::ReportMatchResult { + match_id, + winner_seat: Some(winner_seat), + }, + None, + ) + .unwrap(); + } + + assert_eq!(session.status, DraftStatus::RoundComplete); + + let pairing = session + .pairings + .iter() + .find(|p| p.match_id == "r1-t0") + .unwrap() + .clone(); + + apply( + &mut session, + DraftAction::ReportMatchResult { + match_id: pairing.match_id.clone(), + winner_seat: Some(pairing.players[1].0), + }, + None, + ) + .unwrap(); + + assert_eq!(session.status, DraftStatus::RoundComplete); + let updated_pairing = session + .pairings + .iter() + .find(|p| p.match_id == pairing.match_id) + .unwrap(); + assert_eq!(updated_pairing.winner, Some(pairing.players[1])); + } + + #[test] + fn report_match_result_rejects_non_current_round_pairing() { + let (mut session, _) = test_session(8); + session.status = DraftStatus::Deckbuilding; + + apply( + &mut session, + DraftAction::GeneratePairings { round: 1 }, + None, + ) + .unwrap(); + + let results: Vec<(String, u8)> = session + .pairings + .iter() + .filter(|p| p.round == 1) + .map(|p| (p.match_id.clone(), p.players[0].0)) + .collect(); + + for (match_id, winner_seat) in results { + apply( + &mut session, + DraftAction::ReportMatchResult { + match_id, + winner_seat: Some(winner_seat), + }, + None, + ) + .unwrap(); + } + + apply(&mut session, DraftAction::AdvanceRound, None).unwrap(); + apply( + &mut session, + DraftAction::GeneratePairings { round: 2 }, + None, + ) + .unwrap(); + + let result = apply( + &mut session, + DraftAction::ReportMatchResult { + match_id: "r1-t0".to_string(), + winner_seat: Some(0), + }, + None, + ); + + assert!(matches!( + result, + Err(DraftError::PairingNotInCurrentRound { .. }) + )); } #[test] @@ -1094,14 +1490,19 @@ mod tests { ) .unwrap(); - // Report all 4 match results - for i in 0..4 { - let match_id = format!("r1-t{i}"); + let results: Vec<(String, u8)> = session + .pairings + .iter() + .filter(|p| p.round == 1) + .map(|p| (p.match_id.clone(), p.players[0].0)) + .collect(); + + for (match_id, winner_seat) in results { apply( &mut session, DraftAction::ReportMatchResult { match_id, - winner_seat: Some(i as u8), + winner_seat: Some(winner_seat), }, None, ) @@ -1144,7 +1545,10 @@ mod tests { let deltas = apply( &mut session, - DraftAction::ReplaceSeatWithBot { seat: 3 }, + DraftAction::ReplaceSeatWithBot { + seat: 3, + name: Some("Chandra".to_string()), + }, None, ) .unwrap(); @@ -1152,7 +1556,7 @@ mod tests { assert!(deltas.contains(&DraftDelta::SeatReplacedWithBot { seat: 3 })); assert!(matches!( &session.seats[3], - DraftSeat::Bot { name } if name == "Bot (was Player 3)" + DraftSeat::Bot { name } if name == "Chandra" )); } @@ -1162,7 +1566,10 @@ mod tests { let result = apply( &mut session, - DraftAction::ReplaceSeatWithBot { seat: 10 }, + DraftAction::ReplaceSeatWithBot { + seat: 10, + name: None, + }, None, ); assert!(matches!( diff --git a/crates/draft-core/src/types.rs b/crates/draft-core/src/types.rs index a58a0d0fbd..dcbd0e8637 100644 --- a/crates/draft-core/src/types.rs +++ b/crates/draft-core/src/types.rs @@ -269,6 +269,8 @@ pub enum DraftAction { /// Casual mode: host replaces a human seat with a bot. ReplaceSeatWithBot { seat: u8, + #[serde(default)] + name: Option, }, } @@ -321,6 +323,12 @@ pub enum DraftError { ValidationFailed { errors: Vec }, #[error("pairing not found: {match_id}")] PairingNotFound { match_id: String }, + #[error("pairing {match_id} is not in current round {current_round}")] + PairingNotInCurrentRound { match_id: String, current_round: u8 }, + #[error("single-elimination match {match_id} requires a winner")] + MatchWinnerRequired { match_id: String }, + #[error("seat {seat} is not in pairing {match_id}")] + SeatNotInPairing { seat: u8, match_id: String }, #[error("{format:?} requires {required} seats, got {actual}")] UnsupportedTournamentSize { format: TournamentFormat, @@ -397,6 +405,33 @@ pub struct DraftPairing { pub players: [PlayerId; 2], pub match_id: String, pub status: PairingStatus, + #[serde(default)] + pub winner: Option, +} + +impl DraftPairing { + pub fn result_winner(&self, records: &HashMap) -> Option { + self.winner + .or_else(|| self.infer_winner_from_records(records)) + } + + fn infer_winner_from_records( + &self, + records: &HashMap, + ) -> Option { + if self.status != PairingStatus::Complete { + return None; + } + + let w0 = records.get(&self.players[0]).map_or(0, |r| r.match_wins); + let w1 = records.get(&self.players[1]).map_or(0, |r| r.match_wins); + + match w0.cmp(&w1) { + std::cmp::Ordering::Greater => Some(self.players[0]), + std::cmp::Ordering::Less => Some(self.players[1]), + std::cmp::Ordering::Equal => None, + } + } } /// The full state of a draft session. diff --git a/crates/draft-core/src/view.rs b/crates/draft-core/src/view.rs index 46249bf4ed..8f5d45e6cf 100644 --- a/crates/draft-core/src/view.rs +++ b/crates/draft-core/src/view.rs @@ -365,22 +365,15 @@ fn compute_pairing_views(session: &DraftSession) -> Vec { .cloned() .unwrap_or((0, "Unknown".to_string())); - // Determine winner seat from the match status + records - let winner_seat = if p.status == PairingStatus::Complete { - let r0 = session.match_records.get(&p.players[0]); - let r1 = session.match_records.get(&p.players[1]); - let w0 = r0.map_or(0, |r| r.match_wins); - let w1 = r1.map_or(0, |r| r.match_wins); - if w0 > w1 { + let winner_seat = p.result_winner(&session.match_records).and_then(|winner| { + if winner == p.players[0] { Some(seat_a) - } else if w1 > w0 { + } else if winner == p.players[1] { Some(seat_b) } else { - None // draw or equal + None } - } else { - None - }; + }); PairingView { round: p.round, @@ -741,12 +734,18 @@ mod tests { ) .unwrap(); - // Report seat 0 wins + let winner_pid = session + .pairings + .iter() + .find(|p| p.match_id == "r1-t0") + .unwrap() + .players[0]; + session::apply( &mut session, DraftAction::ReportMatchResult { match_id: "r1-t0".to_string(), - winner_seat: Some(0), + winner_seat: Some(winner_pid.0), }, None, ) @@ -755,10 +754,13 @@ mod tests { let view = filter_for_player(&session, 0); assert!(!view.standings.is_empty()); - // Player 0 should have match_wins = 1 - let p0_standing = view.standings.iter().find(|s| s.seat_index == 0).unwrap(); - assert_eq!(p0_standing.match_wins, 1); - assert_eq!(p0_standing.match_losses, 0); + let winner_standing = view + .standings + .iter() + .find(|s| s.seat_index == winner_pid.0) + .unwrap(); + assert_eq!(winner_standing.match_wins, 1); + assert_eq!(winner_standing.match_losses, 0); // Standings should be sorted by match_wins descending for window in view.standings.windows(2) { @@ -828,6 +830,90 @@ mod tests { } } + #[test] + fn view_pairing_winner_seat_uses_pairing_result() { + let (mut session, _) = test_session(8); + session.status = DraftStatus::Deckbuilding; + + session::apply( + &mut session, + DraftAction::GeneratePairings { round: 1 }, + None, + ) + .unwrap(); + + let pairing = session + .pairings + .iter() + .find(|p| p.match_id == "r1-t0") + .unwrap() + .clone(); + + session::apply( + &mut session, + DraftAction::ReportMatchResult { + match_id: pairing.match_id.clone(), + winner_seat: Some(pairing.players[1].0), + }, + None, + ) + .unwrap(); + + let view = filter_for_player(&session, 0); + let pairing_view = view + .pairings + .iter() + .find(|p| p.match_id == pairing.match_id) + .unwrap(); + assert_eq!(pairing_view.winner_seat, Some(pairing.players[1].0)); + } + + #[test] + fn view_pairing_winner_seat_infers_legacy_completed_result() { + let (mut session, _) = test_session(8); + session.status = DraftStatus::Deckbuilding; + + session::apply( + &mut session, + DraftAction::GeneratePairings { round: 1 }, + None, + ) + .unwrap(); + + let pairing = session + .pairings + .iter() + .find(|p| p.match_id == "r1-t0") + .unwrap() + .clone(); + + session + .pairings + .iter_mut() + .find(|p| p.match_id == pairing.match_id) + .unwrap() + .status = PairingStatus::Complete; + session.match_records.insert( + pairing.players[1], + DraftMatchRecord { + player: pairing.players[1], + wins: 1, + losses: 0, + draws: 0, + match_wins: 1, + match_losses: 0, + }, + ); + + let view = filter_for_player(&session, 0); + let pairing_view = view + .pairings + .iter() + .find(|p| p.match_id == pairing.match_id) + .unwrap(); + assert_eq!(pairing_view.winner_seat, Some(pairing.players[1].0)); + } + #[test] fn pairing_view_score_fields_default_to_none() { // BO3-06: PairingView score_a/score_b are None when match not started. From 0cefbad0d32ca93a444ae49298247c10f8195234 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Mon, 25 May 2026 10:37:44 -0700 Subject: [PATCH 08/22] i18n: translate pacing category labels/descriptions + fr/it Gameplay title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Per-category pacing sliders (Effect/Combat/Banner) rendered hardcoded PACING_LABELS/PACING_DESCRIPTIONS while the Animation Speed slider above them was translated; route both through t(`pacing.labels/descriptions.*`) and drop the now-dead consts from animation/types.ts. - Translate the Gameplay settings tab/section title in fr (Jouabilité) and it (Giocabilità) — every sibling title and the other 4 locales already do. --- client/src/animation/types.ts | 14 +++----------- .../src/components/settings/PreferencesModal.tsx | 6 ++---- client/src/i18n/locales/de/settings.json | 12 +++++++++++- client/src/i18n/locales/en/settings.json | 12 +++++++++++- client/src/i18n/locales/es/settings.json | 12 +++++++++++- client/src/i18n/locales/fr/settings.json | 16 +++++++++++++--- client/src/i18n/locales/it/settings.json | 16 +++++++++++++--- client/src/i18n/locales/pl/settings.json | 12 +++++++++++- client/src/i18n/locales/pt/settings.json | 12 +++++++++++- 9 files changed, 86 insertions(+), 26 deletions(-) diff --git a/client/src/animation/types.ts b/client/src/animation/types.ts index 53fb2b6f90..ef6b559c8f 100644 --- a/client/src/animation/types.ts +++ b/client/src/animation/types.ts @@ -17,17 +17,9 @@ export type PacingCategory = "effects" | "combat" | "banners"; export const PACING_CATEGORIES: readonly PacingCategory[] = ["effects", "combat", "banners"] as const; -export const PACING_LABELS: Record = { - effects: "Effect Pacing", - combat: "Combat Pacing", - banners: "Banner Pacing", -}; - -export const PACING_DESCRIPTIONS: Record = { - effects: "Spell casts, zone changes, deaths, life changes, counters, tap/untap.", - combat: "Combat damage timing — how long blockers and attackers linger before damage resolves.", - banners: "Turn-start banner display.", -}; +// Per-category labels/descriptions are frontend-authored display text and are +// translated at the render site via `t("pacing.labels.")` / +// `t("pacing.descriptions.")` (settings namespace). export const PACING_DEFAULT = 1.0; export const PACING_MIN = 0; diff --git a/client/src/components/settings/PreferencesModal.tsx b/client/src/components/settings/PreferencesModal.tsx index c58d6485a1..3d962dd3e5 100644 --- a/client/src/components/settings/PreferencesModal.tsx +++ b/client/src/components/settings/PreferencesModal.tsx @@ -15,8 +15,6 @@ import { ANIMATION_SPEED_STEP, PACING_CATEGORIES, PACING_DEFAULT, - PACING_DESCRIPTIONS, - PACING_LABELS, PACING_MAX, PACING_MIN, PACING_STEP, @@ -878,8 +876,8 @@ function PacingSection({ {PACING_CATEGORIES.map((category) => ( Date: Mon, 25 May 2026 10:58:55 -0700 Subject: [PATCH 09/22] fix(draft): translate SetSelector load error at render, not in effect The set-load effect referenced `t` for its error fallback but excluded it from the dependency array (eslint exhaustive-deps warning). Adding `t` to deps would re-fetch the draft pools + Scryfall sets on every language change, since react-i18next's `t` changes identity on locale switch. Move translation out of the effect: store the error as `{ detail?: string }` (raw technical message, or undefined for the generic case) and translate at render. The `[]` dependency array is now correct (single fetch on mount), and the error message re-localizes reactively if the language changes while shown. --- client/src/components/draft/SetSelector.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client/src/components/draft/SetSelector.tsx b/client/src/components/draft/SetSelector.tsx index b8b7c55f3e..d176dcc004 100644 --- a/client/src/components/draft/SetSelector.tsx +++ b/client/src/components/draft/SetSelector.tsx @@ -40,7 +40,11 @@ export function SetSelector({ onStartDraft }: SetSelectorProps) { const [sets, setSets] = useState>([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + // `null` = no error. `{ detail }` carries a technical message; `detail` + // undefined means "use the generic translated fallback". Translation happens + // at render so the load effect never closes over `t` (avoids re-fetch on + // language change). + const [error, setError] = useState<{ detail?: string } | null>(null); useEffect(() => { let cancelled = false; @@ -71,7 +75,7 @@ export function SetSelector({ onStartDraft }: SetSelectorProps) { setSets(entries); } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : t("setSelector.loadFailed")); + setError({ detail: err instanceof Error ? err.message : undefined }); } } finally { if (!cancelled) setLoading(false); @@ -123,7 +127,9 @@ export function SetSelector({ onStartDraft }: SetSelectorProps) { {error && ( -
{error}
+
+ {error.detail ?? t("setSelector.loadFailed")} +
)} {!loading && !error && sets.length === 0 && ( From 89b0af2e22c2f4016e5062a21e791fb1f29c430c Mon Sep 17 00:00:00 2001 From: Whovencroft Date: Mon, 25 May 2026 14:53:47 -0400 Subject: [PATCH 10/22] Add Silent Arbiter coverage support Treat parameterized combat declaration cap statics as supported by coverage and add a regression test for Silent Arbiter's attacker/blocker caps. --- crates/engine/src/game/coverage.rs | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index c845703774..eb99157e3f 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -71,6 +71,10 @@ fn is_data_carrying_static(mode: &StaticMode) -> bool { | StaticMode::SuppressTriggers { .. } // CR 603.2d: DoubleTriggers carries the `TriggerCause` predicate. | StaticMode::DoubleTriggers { .. } + // CR 508.1c + CR 509.1b: Combat declaration caps carry the maximum + // count and are enforced by combat.rs declaration validation. + | StaticMode::MaxAttackersEachCombat { .. } + | StaticMode::MaxBlockersEachCombat { .. } // CR 107.4f: PayLifeAsColoredMana carries the `ManaColor` axis // (K'rrik = Black; future printings any other color). | StaticMode::PayLifeAsColoredMana { .. } @@ -9718,4 +9722,32 @@ mod tests { gaps ); } + + /// CR 508.1c + CR 509.1b: declaration-cap statics carry the maximum + /// creature count and are enforced by combat declaration validation rather + /// than exact registry-key lookup. Silent Arbiter is the canonical paired + /// attacker/blocker cap card. + #[test] + fn max_combat_creature_statics_have_no_coverage_gap() { + let mut face = make_face(); + face.oracle_text = Some( + "No more than one creature can attack each combat.\nNo more than one creature can block each combat." + .to_string(), + ); + face.static_abilities.push( + StaticDefinition::new(StaticMode::MaxAttackersEachCombat { max: 1 }) + .description("No more than one creature can attack each combat.".to_string()), + ); + face.static_abilities.push( + StaticDefinition::new(StaticMode::MaxBlockersEachCombat { max: 1 }) + .description("No more than one creature can block each combat.".to_string()), + ); + + let gaps = card_face_gaps(&face); + assert!( + gaps.is_empty(), + "Max combat creature statics should be fully supported, but got gaps: {:?}", + gaps + ); + } } From 7e3c710c19ee3ecc6857423c2176dcec5dfd448c Mon Sep 17 00:00:00 2001 From: acocalypso Date: Mon, 25 May 2026 21:07:52 +0200 Subject: [PATCH 11/22] Add all-colors static parser support Adds parser and coverage support for all-colors static abilities, including Transguild Courier, with maintainer-added class-level parser coverage. --- crates/engine/src/parser/oracle_static.rs | 40 ++++++++++++++++++- .../tests/integration/kaito_integration.rs | 16 +++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/parser/oracle_static.rs b/crates/engine/src/parser/oracle_static.rs index 055488a8a9..34c8a37570 100644 --- a/crates/engine/src/parser/oracle_static.rs +++ b/crates/engine/src/parser/oracle_static.rs @@ -10307,11 +10307,24 @@ fn parse_all_subject_are_color(tp: &TextPair<'_>, description: &str) -> Option Option> { + // CR 105.2: "all colors" / "every color" means the full WUBRG set. + if let Ok((rest, _)) = alt(( + tag::<_, _, OracleError<'_>>("all colors"), + tag("every color"), + )) + .parse(text) + { + if rest.is_empty() { + return Some(ManaColor::ALL.to_vec()); + } + } + if let Some(rest) = nom_tag_lower(text, text, "colorless") { if rest.is_empty() { return Some(Vec::new()); @@ -18388,6 +18401,17 @@ mod tests { ); } + #[test] + fn static_all_creatures_are_all_colors() { + let def = parse_static_line("All creatures are all colors.").unwrap(); + assert_eq!( + def.modifications, + vec![ContinuousModification::SetColor { + colors: ManaColor::ALL.to_vec() + }] + ); + } + #[test] fn static_all_subject_are_color_falls_through_to_land_type_change() { // Regression guard: "All lands are Plains." has a non-color predicate, @@ -18455,6 +18479,18 @@ mod tests { assert!(def.characteristic_defining); } + #[test] + fn static_self_is_all_colors_cda() { + let def = parse_static_line("~ is all colors.").unwrap(); + assert_eq!( + def.modifications, + vec![ContinuousModification::SetColor { + colors: ManaColor::ALL.to_vec() + }] + ); + assert!(def.characteristic_defining); + } + // --- Group A: Chosen color/type creature pump --- #[test] diff --git a/crates/engine/tests/integration/kaito_integration.rs b/crates/engine/tests/integration/kaito_integration.rs index 98893e1fbe..5563210087 100644 --- a/crates/engine/tests/integration/kaito_integration.rs +++ b/crates/engine/tests/integration/kaito_integration.rs @@ -529,10 +529,22 @@ fn kaito_surveil_and_draw() { .hand .len(); - // Activate 0 loyalty ability (Surveil 2, then draw for each opponent who lost life) + // Activate 0 loyalty ability (Surveil 2, then draw for each opponent who lost life). + // Resolve by effect shape instead of hardcoded index; this test runner object + // can carry pre-existing abilities from card data. + let surveil_ability_index = { + let state = runner.state(); + let kaito = state.objects.get(&kaito_id).unwrap(); + kaito + .abilities + .iter() + .position(|ability| matches!(*ability.effect, Effect::Surveil { .. })) + .expect("Kaito should have a surveil loyalty ability") + }; + let result = runner.act(GameAction::ActivateAbility { source_id: kaito_id, - ability_index: 1, // Second ability = surveil+draw + ability_index: surveil_ability_index, }); assert!( result.is_ok(), From 1342970c64a5c665a1309a5d67c3059a6cd40035 Mon Sep 17 00:00:00 2001 From: Whovencroft Date: Mon, 25 May 2026 15:22:16 -0400 Subject: [PATCH 12/22] Add Fleet Swallower coverage support Adds Fleet Swallower support by surfacing target-player attack-trigger target slots, marking TargetZoneCardCount handled, and covering rounded/possessive fractional library quantities. --- crates/engine/src/game/coverage.rs | 16 +++- .../engine/src/parser/oracle_nom/quantity.rs | 20 +++++ crates/engine/src/parser/oracle_trigger.rs | 81 +++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index eb99157e3f..372361ffbd 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -5159,7 +5159,7 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) { QuantityRef::TokensCreatedThisTurn { .. } => ("TokensCreatedThisTurn", Handled), QuantityRef::PlayerActionsThisTurn { .. } => ("PlayerActionsThisTurn", Handled), QuantityRef::DungeonsCompleted => ("DungeonsCompleted", Unhandled), - QuantityRef::TargetZoneCardCount { .. } => ("TargetZoneCardCount", Unhandled), + QuantityRef::TargetZoneCardCount { .. } => ("TargetZoneCardCount", Handled), QuantityRef::CostXPaid => ("CostXPaid", Handled), QuantityRef::KickerCount => ("KickerCount", Handled), QuantityRef::ConvokedCreatureCount => ("ConvokedCreatureCount", Handled), @@ -8776,6 +8776,20 @@ mod tests { ); } + #[test] + fn target_zone_card_count_quantity_feature_is_marked_handled() { + let (name, support) = quantity_ref_feature(&QuantityRef::TargetZoneCardCount { + zone: ZoneRef::Library, + }); + + assert_eq!(name, "TargetZoneCardCount"); + assert_eq!( + support, + FeatureSupport::Handled, + "TargetZoneCardCount is resolved by game::quantity and should not block coverage", + ); + } + // ----------------------------------------------------------------------- // Semantic audit tests // ----------------------------------------------------------------------- diff --git a/crates/engine/src/parser/oracle_nom/quantity.rs b/crates/engine/src/parser/oracle_nom/quantity.rs index 19dd5b0cd4..e682c7b3d0 100644 --- a/crates/engine/src/parser/oracle_nom/quantity.rs +++ b/crates/engine/src/parser/oracle_nom/quantity.rs @@ -4468,6 +4468,26 @@ mod tests { assert_eq!(rest, ""); } + #[test] + fn test_parse_half_your_library_rounded_up() { + let (rest, q) = parse_quantity("half your library, rounded up").unwrap(); + assert_eq!( + q, + QuantityExpr::DivideRounded { + inner: Box::new(QuantityExpr::Ref { + qty: QuantityRef::ZoneCardCount { + zone: ZoneRef::Library, + card_types: Vec::new(), + scope: CountScope::Controller, + }, + }), + divisor: 2, + rounding: RoundingMode::Up, + } + ); + assert_eq!(rest, ""); + } + /// Legacy Oracle text for life-loss cards used "his or her life" before /// the 2014 "their" reword. Resolves to the same `TargetLifeTotal` ref. #[test] diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 4a27d7ddda..fed9087463 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -3907,6 +3907,9 @@ pub(crate) fn parse_trigger_condition( fn execute_references_target_player(effect: &crate::types::ability::Effect) -> bool { fn filter_references(filter: &TargetFilter) -> bool { match filter { + // CR 115.1: Bare `Player` target means the effect explicitly + // targets a player (e.g. "target player mills ..."). + TargetFilter::Player => true, TargetFilter::Typed(TypedFilter { controller, .. }) => { matches!(controller, Some(ControllerRef::TargetPlayer)) } @@ -9896,6 +9899,84 @@ mod tests { assert_eq!(def.mode, TriggerMode::Attacks); } + /// CR 107.1 + CR 701.17a: Attack triggers can use the shared fractional + /// quantity parser to mill the targeted player's library. + #[test] + fn trigger_attacks_target_player_mills_half_their_library_rounded_up() { + use crate::types::ability::{RoundingMode, ZoneRef}; + + let def = parse_trigger_line( + "Whenever this creature attacks, target player mills half their library, rounded up.", + "Fleet Swallower", + ); + assert_eq!(def.mode, TriggerMode::Attacks); + assert_eq!(def.valid_target, Some(TargetFilter::Player)); + + let execute = def.execute.as_ref().expect("trigger should have effect"); + match execute.effect.as_ref() { + Effect::Mill { + count, + target, + destination, + } => { + assert_eq!(*target, TargetFilter::Player); + assert_eq!(*destination, Zone::Graveyard); + assert_eq!( + *count, + QuantityExpr::DivideRounded { + inner: Box::new(QuantityExpr::Ref { + qty: QuantityRef::TargetZoneCardCount { + zone: ZoneRef::Library, + }, + }), + divisor: 2, + rounding: RoundingMode::Up, + }, + ); + } + other => panic!("Expected Mill, got {other:?}"), + } + } + + /// CR 107.1 + CR 701.17a: Sibling coverage — "rounded down" variant + /// exercises the same fractional quantity parser with the opposite rounding + /// mode, ensuring both arms of the `RoundingMode` axis are verified. + #[test] + fn trigger_attacks_target_player_mills_half_their_library_rounded_down() { + use crate::types::ability::{RoundingMode, ZoneRef}; + + let def = parse_trigger_line( + "Whenever this creature attacks, target player mills half their library, rounded down.", + "Test Card", + ); + assert_eq!(def.mode, TriggerMode::Attacks); + + let execute = def.execute.as_ref().expect("trigger should have effect"); + match execute.effect.as_ref() { + Effect::Mill { + count, + target, + destination, + } => { + assert_eq!(*target, TargetFilter::Player); + assert_eq!(*destination, Zone::Graveyard); + assert_eq!( + *count, + QuantityExpr::DivideRounded { + inner: Box::new(QuantityExpr::Ref { + qty: QuantityRef::TargetZoneCardCount { + zone: ZoneRef::Library, + }, + }), + divisor: 2, + rounding: RoundingMode::Down, + }, + ); + } + other => panic!("Expected Mill, got {other:?}"), + } + } + #[test] fn trigger_attacks_and_isnt_blocked_uses_unblocked_mode_and_combat_duration() { let def = parse_trigger_line( From 1e1478295cfecaf754fb50a1741997d0acc72368 Mon Sep 17 00:00:00 2001 From: mike-theDude <53224244+mike-theDude@users.noreply.github.com> Date: Mon, 25 May 2026 21:36:30 +0200 Subject: [PATCH 13/22] Fix batched attack trigger context counts Fixes The Earth King's EventContextAmount count by carrying only matching attackers in batched attack-trigger context, and hardens the matcher against attack target filters. --- crates/engine/src/game/targeting.rs | 4 + crates/engine/src/game/trigger_matchers.rs | 123 ++++++--- crates/engine/src/game/triggers.rs | 274 ++++++++++++++++++++- 3 files changed, 359 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/game/targeting.rs b/crates/engine/src/game/targeting.rs index fc2a9055ba..11057735ca 100644 --- a/crates/engine/src/game/targeting.rs +++ b/crates/engine/src/game/targeting.rs @@ -804,6 +804,10 @@ pub(crate) fn extract_amount_from_event(event: &crate::types::events::GameEvent) GameEvent::CounterAdded { count, .. } => Some(*count as i32), GameEvent::CounterRemoved { count, .. } => Some(*count as i32), GameEvent::Discarded { .. } => Some(1), + // CR 508.1m + CR 603.2c: Batched attack-trigger context stores the + // attackers that satisfied the trigger subject, so "that many" reads + // the size of that contextual attack event. + GameEvent::AttackersDeclared { attacker_ids, .. } => Some(attacker_ids.len() as i32), // CR 706.2: the final number of a die roll is its result. Lets // `EventContextAmount` resolve "where X is the result" pump effects. GameEvent::DieRolled { result, .. } => Some(*result as i32), diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index cb3c6fb8e0..91744fd1d4 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -980,24 +980,7 @@ fn attack_target_matches( source_id: ObjectId, ) -> bool { if let Some(filter) = trigger.attack_target_filter.as_ref() { - let type_matches = matches!( - (filter, target), - ( - crate::types::triggers::AttackTargetFilter::Player, - crate::game::combat::AttackTarget::Player(_) - ) | ( - crate::types::triggers::AttackTargetFilter::Planeswalker, - crate::game::combat::AttackTarget::Planeswalker(_) - ) | ( - crate::types::triggers::AttackTargetFilter::PlayerOrPlaneswalker, - crate::game::combat::AttackTarget::Player(_) - | crate::game::combat::AttackTarget::Planeswalker(_) - ) | ( - crate::types::triggers::AttackTargetFilter::Battle, - crate::game::combat::AttackTarget::Battle(_) - ) - ); - if !type_matches { + if !attack_target_type_matches(target, filter) { return false; } } @@ -1011,7 +994,30 @@ fn attack_target_matches( } } -fn attack_target_defending_player( +pub(super) fn attack_target_type_matches( + target: crate::game::combat::AttackTarget, + filter: &crate::types::triggers::AttackTargetFilter, +) -> bool { + matches!( + (filter, target), + ( + crate::types::triggers::AttackTargetFilter::Player, + crate::game::combat::AttackTarget::Player(_) + ) | ( + crate::types::triggers::AttackTargetFilter::Planeswalker, + crate::game::combat::AttackTarget::Planeswalker(_) + ) | ( + crate::types::triggers::AttackTargetFilter::PlayerOrPlaneswalker, + crate::game::combat::AttackTarget::Player(_) + | crate::game::combat::AttackTarget::Planeswalker(_) + ) | ( + crate::types::triggers::AttackTargetFilter::Battle, + crate::game::combat::AttackTarget::Battle(_) + ) + ) +} + +pub(super) fn attack_target_defending_player( state: &GameState, target: crate::game::combat::AttackTarget, fallback_defending_player: PlayerId, @@ -2225,11 +2231,26 @@ pub(super) fn match_you_attack( source_id: ObjectId, state: &GameState, ) -> bool { - let GameEvent::AttackersDeclared { attacker_ids, .. } = event else { - return false; + !matching_you_attack_pairs(event, trigger, source_id, state).is_empty() +} + +pub(super) fn matching_you_attack_pairs( + event: &GameEvent, + trigger: &TriggerDefinition, + source_id: ObjectId, + state: &GameState, +) -> Vec<(ObjectId, crate::game::combat::AttackTarget)> { + let GameEvent::AttackersDeclared { + attacker_ids, + defending_player, + attacks, + .. + } = event + else { + return Vec::new(); }; if attacker_ids.is_empty() { - return false; + return Vec::new(); } // CR 506.2: the active player is the attacking player; all attackers in // a single AttackersDeclared batch share one controller. @@ -2237,29 +2258,55 @@ pub(super) fn match_you_attack( .iter() .find_map(|id| state.objects.get(id).map(|o| o.controller)) else { - return false; + return Vec::new(); }; // CR 603.2c: the player-scope gate (valid_target). No filter ⇒ legacy // "attackers controlled by the trigger's source controller" semantics. - let player_ok = if trigger.valid_target.is_some() { - valid_player_matches(trigger, state, attacking_player, source_id) - } else { - let source_controller = state.objects.get(&source_id).map(|o| o.controller); - Some(attacking_player) == source_controller + let player_ok = match trigger.valid_target.as_ref() { + // Parser legacy for "one or more creatures attack a player": the + // attacked-player type is represented as `TargetFilter::Player`, not + // as attacking-player scope. The per-attack target filter below handles + // it, so keep the attacking-player gate permissive here. + Some(TargetFilter::Player) => true, + Some(_) => valid_player_matches(trigger, state, attacking_player, source_id), + None => { + let source_controller = state.objects.get(&source_id).map(|o| o.controller); + Some(attacking_player) == source_controller + } }; if !player_ok { - return false; + return Vec::new(); } - // CR 508.1 + CR 603.2c: the attacker-type gate (valid_card). "Attack with one - // or more " fires iff at least one attacker in the batch matches the - // type filter. No filter ⇒ any attacker (current behavior preserved). - match &trigger.valid_card { - None => true, - Some(filter) => attacker_ids - .iter() - .any(|id| target_filter_matches_object(state, *id, filter, source_id)), - } + attacker_ids + .iter() + .filter_map(|id| { + if trigger + .valid_card + .as_ref() + .is_some_and(|filter| !target_filter_matches_object(state, *id, filter, source_id)) + { + return None; + } + let target = attacks + .iter() + .find_map(|(attacker_id, target)| (*attacker_id == *id).then_some(*target)) + .unwrap_or(crate::game::combat::AttackTarget::Player(*defending_player)); + if trigger + .attack_target_filter + .as_ref() + .is_some_and(|filter| !attack_target_type_matches(target, filter)) + { + return None; + } + if matches!(trigger.valid_target, Some(TargetFilter::Player)) + && !matches!(target, crate::game::combat::AttackTarget::Player(_)) + { + return None; + } + Some((*id, target)) + }) + .collect() } /// CR 725.1: Matches when a player becomes the monarch. diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 5e3f02e380..2d5564dab7 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -176,10 +176,59 @@ fn matching_batched_trigger_events( check_trigger_condition(state, condition, controller, Some(obj_id), Some(candidate)) }) }) - .cloned() + .filter_map(|candidate| { + contextual_batched_trigger_event(state, candidate, trig_def, obj_id) + }) .collect() } +fn contextual_batched_trigger_event( + state: &GameState, + event: &GameEvent, + trig_def: &TriggerDefinition, + obj_id: ObjectId, +) -> Option { + let GameEvent::AttackersDeclared { + defending_player, .. + } = event + else { + return Some(event.clone()); + }; + + let matching_attacks = match trig_def.mode { + TriggerMode::Attacks => { + super::trigger_matchers::matching_attack_events(event, trig_def, obj_id, state) + .into_iter() + .flat_map(|event| match event { + GameEvent::AttackersDeclared { attacks, .. } => attacks, + _ => Vec::new(), + }) + .collect() + } + TriggerMode::YouAttack => { + super::trigger_matchers::matching_you_attack_pairs(event, trig_def, obj_id, state) + } + _ => return Some(event.clone()), + }; + + let matching_attackers: Vec<_> = matching_attacks + .iter() + .map(|(attacker_id, _)| *attacker_id) + .collect(); + if matching_attackers.is_empty() { + return None; + } + + // CR 603.2c + CR 608.2c: batched "one or more ... attack" triggers fire + // once, but later "that many" text refers to the members of that matching + // event subset, not every attacker in the declaration. + Some(GameEvent::AttackersDeclared { + attacker_ids: matching_attackers, + defending_player: *defending_player, + attacks: matching_attacks, + }) +} + #[allow(clippy::too_many_arguments)] fn collect_matching_triggers( state: &GameState, @@ -3499,9 +3548,10 @@ pub mod tests { AggregateFunction, ChosenAttribute, ChosenSubtypeKind, CommanderOwnership, Comparator, ContinuousModification, ControllerRef, DelayedTriggerCondition, Duration, Effect, FilterProp, GainLifePlayer, KickerVariant, MultiTargetSpec, PaymentCost, PlayerFilter, - PlayerScope, QuantityExpr, QuantityRef, ResolvedAbility, SharedQuality, - SharedQualityRelation, StaticCondition, StaticDefinition, TargetFilter, TargetRef, - TriggerCondition, TriggerConstraint, TriggerDefinition, TypeFilter, TypedFilter, + PlayerScope, PtStat, PtValueScope, QuantityExpr, QuantityRef, ResolvedAbility, + SearchSelectionConstraint, SharedQuality, SharedQualityRelation, StaticCondition, + StaticDefinition, TargetFilter, TargetRef, TriggerCondition, TriggerConstraint, + TriggerDefinition, TypeFilter, TypedFilter, }; use crate::types::actions::GameAction; use crate::types::card_type::CoreType; @@ -11554,6 +11604,222 @@ pub mod tests { ); } + /// Issue #1055: The Earth King stores "that many" as EventContextAmount. + /// For a batched attack trigger, that amount is the number of attackers + /// that matched the trigger subject, not the raw number of all attackers. + #[test] + fn issue_1055_batched_attack_search_uses_matching_attacker_count() { + use crate::game::combat::AttackTarget; + + fn add_basic_land(state: &mut GameState, name: &str) -> ObjectId { + let id = create_object( + state, + CardId(state.next_object_id), + PlayerId(0), + name.to_string(), + Zone::Library, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Land); + obj.card_types + .supertypes + .push(crate::types::card_type::Supertype::Basic); + id + } + + let mut state = setup(); + state.active_player = PlayerId(0); + state.priority_player = PlayerId(0); + + let source = make_creature(&mut state, PlayerId(0), "The Earth King", 4, 4); + let mut trigger = + TriggerDefinition::new(TriggerMode::YouAttack).execute(AbilityDefinition::new( + AbilityKind::Database, + Effect::SearchLibrary { + filter: TargetFilter::Typed(TypedFilter::land().properties(vec![ + FilterProp::HasSupertype { + value: crate::types::card_type::Supertype::Basic, + }, + ])), + count: QuantityExpr::up_to(QuantityExpr::Ref { + qty: QuantityRef::EventContextAmount, + }), + reveal: false, + target_player: None, + selection_constraint: SearchSelectionConstraint::None, + split: None, + }, + )); + trigger.batched = true; + trigger.valid_card = Some(TargetFilter::Typed( + TypedFilter::creature() + .controller(ControllerRef::You) + .properties(vec![FilterProp::PtComparison { + stat: PtStat::Power, + scope: PtValueScope::Current, + comparator: Comparator::GE, + value: QuantityExpr::Fixed { value: 4 }, + }]), + )); + state + .objects + .get_mut(&source) + .unwrap() + .trigger_definitions + .push(trigger); + + let big_one = make_creature(&mut state, PlayerId(0), "Big One", 4, 4); + let big_two = make_creature(&mut state, PlayerId(0), "Big Two", 5, 5); + let small = make_creature(&mut state, PlayerId(0), "Small", 3, 3); + add_basic_land(&mut state, "Plains"); + add_basic_land(&mut state, "Island"); + add_basic_land(&mut state, "Forest"); + + process_triggers( + &mut state, + &[GameEvent::AttackersDeclared { + attacker_ids: vec![big_one, big_two, small], + defending_player: PlayerId(1), + attacks: vec![ + (big_one, AttackTarget::Player(PlayerId(1))), + (big_two, AttackTarget::Player(PlayerId(1))), + (small, AttackTarget::Player(PlayerId(1))), + ], + }], + ); + + assert_eq!(state.stack.len(), 1, "The Earth King trigger should fire"); + let mut events = Vec::new(); + crate::game::stack::resolve_top(&mut state, &mut events); + + match &state.waiting_for { + WaitingFor::SearchChoice { + count, + up_to, + cards, + .. + } => { + assert_eq!(*count, 2, "two power-4+ attackers set the search cap"); + assert!(*up_to, "The Earth King's search is up to that many"); + assert_eq!(cards.len(), 3, "all three basic lands remain legal choices"); + } + other => panic!("expected SearchChoice, got {other:?}"), + } + } + + #[test] + fn batched_attack_context_filters_attacked_target_type() { + use crate::game::combat::AttackTarget; + + fn add_basic_land(state: &mut GameState, name: &str) { + let id = create_object( + state, + CardId(state.next_object_id), + PlayerId(0), + name.to_string(), + Zone::Library, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Land); + obj.card_types + .supertypes + .push(crate::types::card_type::Supertype::Basic); + } + + let mut state = setup(); + state.active_player = PlayerId(0); + state.priority_player = PlayerId(0); + + let source = make_creature(&mut state, PlayerId(0), "Attack Trigger Source", 4, 4); + let mut trigger = + TriggerDefinition::new(TriggerMode::YouAttack).execute(AbilityDefinition::new( + AbilityKind::Database, + Effect::SearchLibrary { + filter: TargetFilter::Typed(TypedFilter::land().properties(vec![ + FilterProp::HasSupertype { + value: crate::types::card_type::Supertype::Basic, + }, + ])), + count: QuantityExpr::up_to(QuantityExpr::Ref { + qty: QuantityRef::EventContextAmount, + }), + reveal: false, + target_player: None, + selection_constraint: SearchSelectionConstraint::None, + split: None, + }, + )); + trigger.batched = true; + trigger.valid_card = Some(TargetFilter::Typed(TypedFilter::creature().properties( + vec![FilterProp::PtComparison { + stat: PtStat::Power, + scope: PtValueScope::Current, + comparator: Comparator::GE, + value: QuantityExpr::Fixed { value: 4 }, + }], + ))); + trigger.attack_target_filter = Some(AttackTargetFilter::Player); + state + .objects + .get_mut(&source) + .unwrap() + .trigger_definitions + .push(trigger); + + let attacks_player = make_creature(&mut state, PlayerId(0), "Attacks Player", 4, 4); + let attacks_planeswalker = + make_creature(&mut state, PlayerId(0), "Attacks Planeswalker", 5, 5); + let small_attacks_player = + make_creature(&mut state, PlayerId(0), "Small Attacks Player", 3, 3); + let planeswalker = create_object( + &mut state, + CardId(9000), + PlayerId(1), + "Target Planeswalker".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&planeswalker) + .unwrap() + .card_types + .core_types + .push(CoreType::Planeswalker); + add_basic_land(&mut state, "Plains"); + add_basic_land(&mut state, "Island"); + add_basic_land(&mut state, "Forest"); + + process_triggers( + &mut state, + &[GameEvent::AttackersDeclared { + attacker_ids: vec![attacks_player, attacks_planeswalker, small_attacks_player], + defending_player: PlayerId(1), + attacks: vec![ + (attacks_player, AttackTarget::Player(PlayerId(1))), + ( + attacks_planeswalker, + AttackTarget::Planeswalker(planeswalker), + ), + (small_attacks_player, AttackTarget::Player(PlayerId(1))), + ], + }], + ); + + assert_eq!(state.stack.len(), 1, "filtered attack trigger should fire"); + let mut events = Vec::new(); + crate::game::stack::resolve_top(&mut state, &mut events); + + match &state.waiting_for { + WaitingFor::SearchChoice { count, .. } => { + assert_eq!( + *count, 1, + "only the power-4+ creature attacking a player contributes to that-many" + ); + } + other => panic!("expected SearchChoice, got {other:?}"), + } + } + /// Issue #501 (class coverage): The Tenth Doctor's `Allons-y!` attack /// trigger exiles cards until a nonland is exiled, puts three time counters /// on it, and grants it Suspend — the same runtime-granted-Suspend pattern From 986618aa63c059f96ec48e98021c71544fc7ad86 Mon Sep 17 00:00:00 2001 From: Whovencroft Date: Mon, 25 May 2026 16:08:46 -0400 Subject: [PATCH 14/22] Add noncreature damage-source parser support Extends the damage-source parser's type axis to handle noncreature sources, unlocking Tamanoa, with parser coverage for controlled source and player-target variants. --- crates/engine/src/parser/oracle_trigger.rs | 48 +++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index fed9087463..f0f062a525 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -5492,8 +5492,21 @@ fn parse_damage_source_subject(input: &str) -> OracleResult<'_, TargetFilter> { // misclassify the subject. let (rest, color) = opt(terminated(nom_primitives::parse_color, tag(" "))).parse(rest)?; + // CR 109.4: Head noun — "source" (any), a card type, or a negated type + // prefix ("noncreature source"). The negated variant uses + // `TypeFilter::Non(Box::new(…))` so the runtime filter excludes that type. let (rest, head_type) = alt(( - value(None, tag::<_, _, OracleError<'_>>("source")), + value( + Some(TypeFilter::Non(Box::new(TypeFilter::Creature))), + ( + tag::<_, _, OracleError<'_>>("noncreature source"), + opt(tag("s")), + ), + ), + value( + None, + (tag::<_, _, OracleError<'_>>("source"), opt(tag("s"))), + ), value(Some(TypeFilter::Creature), tag("creature")), value(Some(TypeFilter::Artifact), tag("artifact")), value(Some(TypeFilter::Enchantment), tag("enchantment")), @@ -15695,6 +15708,39 @@ mod tests { assert_eq!(def.valid_target, Some(TargetFilter::Player)); } + #[test] + fn trigger_noncreature_source_you_control_deals_damage() { + let def = parse_trigger_line( + "Whenever a noncreature source you control deals damage, you gain that much life.", + "Tamanoa", + ); + assert_eq!(def.mode, TriggerMode::DamageDone); + assert_eq!(def.damage_kind, DamageKindFilter::Any); + assert_eq!(def.damage_amount, None); + assert_eq!( + def.valid_source, + Some(TargetFilter::Typed( + TypedFilter::new(TypeFilter::Non(Box::new(TypeFilter::Creature))) + .controller(ControllerRef::You) + )) + ); + assert_eq!(def.valid_target, None); + } + #[test] + fn trigger_noncreature_source_deals_damage_to_player() { + let def = parse_trigger_line( + "Whenever a noncreature source deals damage to a player, draw a card.", + "Test", + ); + assert_eq!(def.mode, TriggerMode::DamageDone); + assert_eq!( + def.valid_source, + Some(TargetFilter::Typed(TypedFilter::new(TypeFilter::Non( + Box::new(TypeFilter::Creature) + )))) + ); + assert_eq!(def.valid_target, Some(TargetFilter::Player)); + } // ── Work Item 4: Transforms Into Self ───────────────────────── #[test] From ced45dd97aab73940e411c5f91e6121a798c45ab Mon Sep 17 00:00:00 2001 From: Whovencroft Date: Mon, 25 May 2026 16:30:31 -0400 Subject: [PATCH 15/22] Support opponent-controlled damage source triggers Extends source-led damage trigger parsing for opponent-controlled sources and hardens source-controller anaphors so Michiko-style sacrifice effects resolve against the damage source controller. --- crates/engine/src/game/effects/sacrifice.rs | 44 ++++++++ crates/engine/src/game/filter.rs | 49 +++++---- crates/engine/src/parser/oracle_effect/mod.rs | 18 ++-- .../src/parser/oracle_effect/subject.rs | 21 ++++ crates/engine/src/parser/oracle_trigger.rs | 102 ++++++++++++++++-- 5 files changed, 196 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/game/effects/sacrifice.rs b/crates/engine/src/game/effects/sacrifice.rs index 5df3d0aa95..e0776b94fe 100644 --- a/crates/engine/src/game/effects/sacrifice.rs +++ b/crates/engine/src/game/effects/sacrifice.rs @@ -764,6 +764,50 @@ mod tests { } } + #[test] + fn parent_target_controller_scope_uses_damage_event_source_controller() { + let mut state = GameState::new_two_player(42); + let damage_source = create_object( + &mut state, + CardId(1), + PlayerId(1), + "Damage Source".to_string(), + Zone::Stack, + ); + let own = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Mine".to_string(), + Zone::Battlefield, + ); + let source_controller_permanent = create_object( + &mut state, + CardId(3), + PlayerId(1), + "Theirs".to_string(), + Zone::Battlefield, + ); + state.current_trigger_event = Some(GameEvent::DamageDealt { + source_id: damage_source, + target: TargetRef::Player(PlayerId(0)), + amount: 1, + is_combat: false, + excess: 0, + }); + let ability = make_scoped_sacrifice_ability(ControllerRef::ParentTargetController, vec![]); + let mut events = Vec::new(); + + resolve(&mut state, &ability, &mut events).unwrap(); + + assert!(state.battlefield.contains(&own)); + assert!(!state.battlefield.contains(&source_controller_permanent)); + assert!(state.players[1] + .graveyard + .contains(&source_controller_permanent)); + assert_eq!(state.last_effect_count, Some(1)); + } + #[test] fn scoped_player_scope_uses_trigger_event_player() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 124d9f22f5..3e29b54203 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -191,6 +191,19 @@ fn scoped_player_or_controller( ability.and_then(|a| a.scoped_player).or(source_controller) } +fn parent_target_controller_player( + state: &GameState, + ability: Option<&ResolvedAbility>, +) -> Option { + ability.and_then(|a| { + crate::game::targeting::resolve_effect_player_ref( + state, + a, + &TargetFilter::ParentTargetController, + ) + }) +} + fn controller_ref_player( state: &GameState, source_id: ObjectId, @@ -208,9 +221,7 @@ fn controller_ref_player( TargetRef::Object(_) => None, }) }), - ControllerRef::ParentTargetController => { - ability.and_then(|a| crate::game::ability_utils::parent_target_controller(a, state)) - } + ControllerRef::ParentTargetController => parent_target_controller_player(state, ability), ControllerRef::DefendingPlayer => { crate::game::combat::defending_player_for_attacker(state, source_id) } @@ -520,9 +531,7 @@ fn filter_inner_for_object( } } ControllerRef::ParentTargetController => { - let target_player = ability.and_then(|a| { - crate::game::ability_utils::parent_target_controller(a, state) - }); + let target_player = parent_target_controller_player(state, ability); match target_player { Some(pid) if pid == obj.controller => {} _ => return false, @@ -815,9 +824,7 @@ fn zone_change_filter_inner( } } ControllerRef::ParentTargetController => { - let target_player = ability.and_then(|a| { - crate::game::ability_utils::parent_target_controller(a, state) - }); + let target_player = parent_target_controller_player(state, ability); match target_player { Some(pid) if pid == record.controller => {} _ => return false, @@ -1955,10 +1962,10 @@ fn matches_filter_prop( }) }) .is_some_and(|pid| pid == obj.owner), - ControllerRef::ParentTargetController => source - .ability - .and_then(|a| crate::game::ability_utils::parent_target_controller(a, state)) - .is_some_and(|pid| pid == obj.owner), + ControllerRef::ParentTargetController => { + parent_target_controller_player(state, source.ability) + .is_some_and(|pid| pid == obj.owner) + } ControllerRef::DefendingPlayer => { crate::game::combat::defending_player_for_attacker(state, source.id) .is_some_and(|pid| pid == obj.owner) @@ -2466,10 +2473,10 @@ fn zone_change_record_matches_property( }) }) .is_some_and(|pid| pid == record.owner), - ControllerRef::ParentTargetController => source - .ability - .and_then(|a| crate::game::ability_utils::parent_target_controller(a, state)) - .is_some_and(|pid| pid == record.owner), + ControllerRef::ParentTargetController => { + parent_target_controller_player(state, source.ability) + .is_some_and(|pid| pid == record.owner) + } ControllerRef::DefendingPlayer => { crate::game::combat::defending_player_for_attacker(state, source.id) .is_some_and(|pid| pid == record.owner) @@ -2654,10 +2661,10 @@ fn attachment_controller_matches( }) }) .is_some_and(|pid| pid == attachment_controller), - Some(ControllerRef::ParentTargetController) => source - .ability - .and_then(|a| crate::game::ability_utils::parent_target_controller(a, state)) - .is_some_and(|pid| pid == attachment_controller), + Some(ControllerRef::ParentTargetController) => { + parent_target_controller_player(state, source.ability) + .is_some_and(|pid| pid == attachment_controller) + } Some(ControllerRef::DefendingPlayer) => { combat::defending_player_for_attacker(state, source.id) .is_some_and(|pid| pid == attachment_controller) diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 932adef8d4..969f5df31c 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -2265,16 +2265,14 @@ fn parse_event_context_ref_with_ctx<'a>( ctx: &ParseContext, ) -> Option<(TargetFilter, &'a str)> { let (target, rest) = parse_event_context_ref(text)?; - let target = if matches!( - (&target, ctx.relative_player_scope.as_ref()), - ( - TargetFilter::TriggeringPlayer, - Some(ControllerRef::ScopedPlayer) - ) - ) { - TargetFilter::ScopedPlayer - } else { - target + let target = match (&target, ctx.relative_player_scope.as_ref()) { + (TargetFilter::TriggeringPlayer, Some(ControllerRef::ScopedPlayer)) => { + TargetFilter::ScopedPlayer + } + (TargetFilter::TriggeringPlayer, Some(ControllerRef::ParentTargetController)) => { + TargetFilter::ParentTargetController + } + _ => target, }; Some((target, rest)) } diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index 140514aa69..b932f167fc 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -862,6 +862,11 @@ pub(super) fn parse_subject_application( }) } else if matches!(ctx.relative_player_scope, Some(ControllerRef::ScopedPlayer)) { TargetFilter::ScopedPlayer + } else if matches!( + ctx.relative_player_scope, + Some(ControllerRef::ParentTargetController) + ) { + TargetFilter::ParentTargetController } else if ctx.subject.is_some() { ctx_filter } else { @@ -3222,6 +3227,22 @@ mod tests { assert_eq!(result.unwrap().affected, TargetFilter::TriggeringPlayer); } + #[test] + fn parse_subject_that_player_trigger_context_honors_parent_target_controller_scope() { + let mut ctx = ParseContext { + subject: Some(TargetFilter::SelfRef), + relative_player_scope: Some(ControllerRef::ParentTargetController), + ..ParseContext::default() + }; + let result = parse_subject_application("that player", &mut ctx); + + assert!(result.is_some()); + assert_eq!( + result.unwrap().affected, + TargetFilter::ParentTargetController + ); + } + // CR 115.1d: "any number of target" subject prefix tests #[test] fn parse_subject_any_number_of_target_creatures() { diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index f0f062a525..60bbc82102 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -546,6 +546,38 @@ fn condition_introduces_target_player(cond_lower: &str) -> bool { false } +fn condition_introduces_damage_source_controller_player(cond_lower: &str) -> bool { + let input = cond_lower.trim_start(); + let input = alt(( + value((), tag::<_, _, OracleError<'_>>("whenever ")), + value((), tag("when ")), + )) + .parse(input) + .map(|(rest, _)| rest) + .unwrap_or(input); + let Ok((rest, source_filter)) = parse_damage_source_subject(input) else { + return false; + }; + let TargetFilter::Typed(TypedFilter { + controller: Some(ControllerRef::Opponent), + .. + }) = source_filter + else { + return false; + }; + let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("deals ").parse(rest) else { + return false; + }; + let Ok((after_damage, _)) = parse_damage_predicate_tail(rest) else { + return false; + }; + + matches!( + parse_damage_to_qualifier(after_damage), + Some(TargetFilter::Controller) + ) +} + /// Parse a full trigger line into a TriggerDefinition. /// Input: a line starting with "When", "Whenever", or "At". /// The card_name is used for self-reference substitution. @@ -640,7 +672,9 @@ pub(crate) fn parse_trigger_line_with_index_ir( // CR 109.4 + CR 115.1 + CR 506.2: Set relative-player scope for // TargetPlayer resolution inside the trigger effect body. - if condition_introduces_target_player(&cond_lower) { + if condition_introduces_damage_source_controller_player(&cond_lower) { + effect_ctx.relative_player_scope = Some(ControllerRef::ParentTargetController); + } else if condition_introduces_target_player(&cond_lower) { effect_ctx.relative_player_scope = Some(ControllerRef::TargetPlayer); } else if condition_introduces_scoped_phase_player(&cond_lower) { effect_ctx.relative_player_scope = Some(ControllerRef::ScopedPlayer); @@ -5516,12 +5550,19 @@ fn parse_damage_source_subject(input: &str) -> OracleResult<'_, TargetFilter> { )) .parse(rest)?; - // Optional " you control" controller scope. Absence → no controller - // restriction (matches any source — Phyrexian Obliterator class, deferred). - let (rest, controller) = opt(value( - ControllerRef::You, - tag::<_, _, OracleError<'_>>(" you control"), - )) + // Optional controller scope. Absence → no controller restriction + // (matches any source — Phyrexian Obliterator class, deferred). + // CR 109.4: "a source you control" / "a source an opponent controls". + let (rest, controller) = opt(alt(( + value( + ControllerRef::You, + tag::<_, _, OracleError<'_>>(" you control"), + ), + value( + ControllerRef::Opponent, + tag::<_, _, OracleError<'_>>(" an opponent controls"), + ), + ))) .parse(rest)?; // Require trailing space before the "deals" verb so we don't match @@ -15708,6 +15749,24 @@ mod tests { assert_eq!(def.valid_target, Some(TargetFilter::Player)); } + #[test] + fn trigger_source_opponent_controls_deals_damage_to_you() { + let def = parse_trigger_line( + "Whenever a source an opponent controls deals damage to you, you may put that many +1/+1 counters on ~.", + "Retaliator Griffin", + ); + assert_eq!(def.mode, TriggerMode::DamageDone); + assert_eq!(def.damage_kind, DamageKindFilter::Any); + assert_eq!(def.damage_amount, None); + assert_eq!( + def.valid_source, + Some(TargetFilter::Typed( + TypedFilter::default().controller(ControllerRef::Opponent) + )) + ); + assert_eq!(def.valid_target, Some(TargetFilter::Controller)); + } + #[test] fn trigger_noncreature_source_you_control_deals_damage() { let def = parse_trigger_line( @@ -15726,6 +15785,35 @@ mod tests { ); assert_eq!(def.valid_target, None); } + + #[test] + fn trigger_source_opponent_controls_deals_damage_to_you_michiko() { + let def = parse_trigger_line( + "Whenever a source an opponent controls deals damage to you, that player sacrifices a permanent.", + "Michiko Konda, Truth Seeker", + ); + assert_eq!(def.mode, TriggerMode::DamageDone); + assert_eq!( + def.valid_source, + Some(TargetFilter::Typed( + TypedFilter::default().controller(ControllerRef::Opponent) + )) + ); + assert_eq!(def.valid_target, Some(TargetFilter::Controller)); + let execute = def.execute.as_ref().expect("trigger execute"); + match execute.effect.as_ref() { + Effect::Sacrifice { target, .. } => match target { + TargetFilter::Typed(TypedFilter { + type_filters, + controller: Some(ControllerRef::ParentTargetController), + .. + }) => assert_eq!(type_filters.as_slice(), [TypeFilter::Permanent]), + other => panic!("expected source-controller sacrifice filter, got {other:?}"), + }, + other => panic!("expected Sacrifice, got {other:?}"), + } + } + #[test] fn trigger_noncreature_source_deals_damage_to_player() { let def = parse_trigger_line( From e294b3bedc4659c154e1f72c076ffbee15396881 Mon Sep 17 00:00:00 2001 From: mike-theDude <53224244+mike-theDude@users.noreply.github.com> Date: Mon, 25 May 2026 22:47:21 +0200 Subject: [PATCH 16/22] Fix relative attack target controller parsing Threads relative-player scope through attack-trigger target parsing so effects like Gornog target permanents controlled by the attacked player, with additional coverage for sibling target-subject forms. --- .../src/parser/oracle_effect/subject.rs | 64 ++++++++++++++++-- crates/engine/src/parser/oracle_trigger.rs | 65 +++++++++++++++++-- 2 files changed, 116 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index b932f167fc..3fa8e5e9b9 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -27,7 +27,7 @@ use super::super::oracle_static::{ classify_block_exception, parse_additive_type_clause_modifications, parse_chosen_qualifier_subject, parse_continuous_modifications, parse_static_line_multi, }; -use super::super::oracle_target::{parse_target, parse_type_phrase}; +use super::super::oracle_target::{parse_target, parse_target_with_ctx, parse_type_phrase}; use super::super::oracle_util::{ parse_number, TextPair, SELF_REF_PARSE_ONLY_PHRASES, SELF_REF_TYPE_PHRASES, }; @@ -626,7 +626,7 @@ pub(super) fn parse_subject_application( .parse(lower.as_str()) .is_ok() { - let (filter, _) = parse_target(&subject["another ".len()..]); + let (filter, _) = parse_target_with_ctx(&subject["another ".len()..], ctx); let filter = add_another_property(filter); return subject_filter_application(filter, true); } @@ -634,7 +634,16 @@ pub(super) fn parse_subject_application( .parse(lower.as_str()) .is_ok() { - let (filter, _) = parse_target(subject); + // CR 109.4 + CR 115.1 + CR 603.2: thread the parse context so that + // controller-suffix resolution inside `parse_target` (notably the + // "that player controls" relative reference) can see the enclosing + // trigger's `relative_player_scope` and emit + // `ControllerRef::TargetPlayer` for the attacked / damaged player + // instead of falling back to `You`. Without `ctx`, the subject-form + // path of "target creature that player controls becomes …" (Gornog, + // the Red Reaper) silently bound the target to the trigger + // controller's own creatures. + let (filter, _) = parse_target_with_ctx(subject, ctx); return subject_filter_application(filter, true); } if tag::<_, _, OracleError<'_>>("up to ") @@ -643,7 +652,7 @@ pub(super) fn parse_subject_application( { let (target_text, multi_target) = super::strip_optional_target_prefix(subject); if multi_target.is_some() { - let (filter, _) = parse_target(target_text); + let (filter, _) = parse_target_with_ctx(target_text, ctx); let mut application = subject_filter_application(filter, true)?; application.multi_target = multi_target; return Some(application); @@ -661,7 +670,7 @@ pub(super) fn parse_subject_application( .parse(after_prefix) .is_ok() { - let (filter, _) = parse_target(target_text); + let (filter, _) = parse_target_with_ctx(target_text, ctx); let mut application = subject_filter_application(filter, true)?; application.multi_target = Some(MultiTargetSpec::unlimited(0)); return Some(application); @@ -683,7 +692,7 @@ pub(super) fn parse_subject_application( { let consumed = lower.len() - after_prefix.len(); let target_text = &subject[consumed..]; - let (filter, _) = parse_target(target_text); + let (filter, _) = parse_target_with_ctx(target_text, ctx); let mut application = subject_filter_application(filter, true)?; application.multi_target = Some(MultiTargetSpec::fixed(min, max)); return Some(application); @@ -3280,6 +3289,49 @@ mod tests { assert_eq!(app.multi_target, Some(MultiTargetSpec::unlimited(0)),); } + #[test] + fn parse_subject_another_target_honors_relative_player_scope() { + let mut ctx = ParseContext { + relative_player_scope: Some(ControllerRef::TargetPlayer), + ..ParseContext::default() + }; + let result = + parse_subject_application("another target creature that player controls", &mut ctx); + assert!(result.is_some()); + let app = result.unwrap(); + assert!( + matches!(app.affected, TargetFilter::Typed(ref t) + if t.type_filters.contains(&TypeFilter::Creature) + && t.controller == Some(ControllerRef::TargetPlayer) + && t.properties.iter().any(|prop| matches!(prop, FilterProp::Another))), + "should parse another creature controlled by target player, got {:?}", + app.affected + ); + } + + #[test] + fn parse_subject_up_to_one_target_honors_relative_player_scope() { + let mut ctx = ParseContext { + relative_player_scope: Some(ControllerRef::TargetPlayer), + ..ParseContext::default() + }; + let result = + parse_subject_application("up to one target creature that player controls", &mut ctx); + assert!(result.is_some()); + let app = result.unwrap(); + assert!( + matches!(app.affected, TargetFilter::Typed(ref t) + if t.type_filters.contains(&TypeFilter::Creature) + && t.controller == Some(ControllerRef::TargetPlayer)), + "should parse creature controlled by target player, got {:?}", + app.affected + ); + assert_eq!( + app.multi_target, + Some(MultiTargetSpec::up_to(QuantityExpr::Fixed { value: 1 })) + ); + } + #[test] fn parse_subject_any_number_of_target_players() { let mut ctx = ParseContext::default(); diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 60bbc82102..b1d163d740 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -462,14 +462,18 @@ fn strip_first_time_each_turn_qualifier(condition: &str) -> (String, bool) { } /// CR 109.4 + CR 115.1 + CR 506.2: Detect a trigger condition that introduces -/// a player target — currently the "you/an opponent/a player attack(s) a player" -/// family. When this returns true, follow-on possessive references inside the -/// effect ("that player controls/owns") refer to that introduced player and the -/// parser pushes a relative-player scope so they emit `ControllerRef::TargetPlayer`. +/// a player target — currently the "[subject] attack(s) a player" family +/// (CR 506.2 / CR 508.1a) and the "[subject] deals [combat] damage to a player" +/// family (CR 120.3). When this returns true, follow-on possessive references +/// inside the effect ("that player controls/owns") refer to that introduced +/// player and the parser pushes a relative-player scope so they emit +/// `ControllerRef::TargetPlayer`. /// -/// Built from composable nom alternatives so adding new condition shapes -/// (combat-damage-to-a-player, "deals damage to a player", etc.) is a one-line -/// change to the inner `alt()`. +/// Built from composable nom alternatives so adding new condition shapes is a +/// one-line change to the inner `alt()`. The attack/damage scans both accept +/// any subject prefix (verb-phrase only), so relative-clause subjects like +/// "one or more Warriors you control" and "a creature you control" match +/// without needing an explicit-actor variant per subject shape. fn condition_introduces_target_player(cond_lower: &str) -> bool { use nom::bytes::complete::tag; use nom::combinator::value; @@ -523,6 +527,21 @@ fn condition_introduces_target_player(cond_lower: &str) -> bool { } } } + // CR 506.2 + CR 508.1a: "[anything] attack[s] a player" — same subject + // permissiveness as the damage scan below. Covers cases where the + // actor is wrapped in a relative clause that the explicit actor branch + // above cannot match, e.g. "one or more Warriors you control attack a + // player" (Gornog, the Red Reaper) or "a creature you control attacks + // a player". The verb phrase alone is unambiguous in trigger-condition + // text — "attack" never appears as a noun before "a player" here. + if let Ok((after_verb, ())) = parse_attack_verb(remaining) { + if tag::<_, _, OracleError<'_>>("a player") + .parse(after_verb) + .is_ok() + { + return true; + } + } // CR 120.3: "[anything] deals [combat] damage to a player" — introduces // the damaged player as the target-referring player. The subject can be // SelfRef ("~"), equipped creature ("equipped creature"), or any typed @@ -19887,6 +19906,38 @@ mod tests { } } + /// CR 109.4 + CR 115.1 + CR 506.2: Gornog's "Whenever one or more Warriors + /// you control attack a player, target creature that player controls + /// becomes a Coward" introduces the attacked player through a relative- + /// clause subject ("Warriors you control") rather than a bare actor. The + /// effect's "that player controls" must still resolve to + /// `ControllerRef::TargetPlayer` so the UI offers the attacked player's + /// creatures, not the trigger controller's. Regression test for #1054 — the + /// subject-permissive scan in `condition_introduces_target_player` is the + /// fix site. + #[test] + fn gornog_one_or_more_warriors_attack_uses_target_player_controller() { + use crate::types::ability::Effect; + + let def = parse_trigger_line( + "Whenever one or more Warriors you control attack a player, target creature that player controls becomes a Coward.", + "Gornog, the Red Reaper", + ); + assert_eq!(def.mode, TriggerMode::YouAttack); + let execute = def.execute.as_deref().expect("execute ability"); + match execute.effect.as_ref() { + Effect::GenericEffect { target, .. } => match target { + Some(TargetFilter::Typed(t)) => assert_eq!( + t.controller, + Some(ControllerRef::TargetPlayer), + "GenericEffect target should reference the attacked player", + ), + other => panic!("expected Some(Typed) target filter, got {other:?}"), + }, + other => panic!("expected GenericEffect, got {other:?}"), + } + } + /// Negative scope test — a non-attack-player trigger ("Whenever you draw a /// card") MUST NOT push the relative-player scope, so "that player controls" /// inside the effect (synthetic but exercising the parser) still defaults to From 5860d7bb882456338e7d9f3fc45c16e512a206bb Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Mon, 25 May 2026 13:27:17 -0700 Subject: [PATCH 17/22] feat(card-bot): Discord /card engine-parse lookup bot Bun HTTP-interactions service (Docker on the VPS behind nginx) that renders how the engine parses any card, mirroring the in-app Alt-hover ENGINE PARSE overlay. - Parse breakdown from coverage-data.json on R2 (preview=/staging default, release=root); image/type/mana from the same scryfall-data.json the app reads. Both cached in-memory, warmed on boot; coverage uses ETag stale-while-revalidate. - Mana as application-owned emoji (upload-emoji.ts -> manaEmoji.json); colored category squares, coverage meter, oracle blockquote, red-x + strikethrough for unsupported items. - /card name: [build]; Ed25519-verified; runtime container is secretless (only register.ts needs the bot token). feat(card-bot): auto-refresh scryfall cache; gate autocomplete at 2 chars - scryfall-data.json now uses the same ETag stale-while-revalidate as coverage (hourly), so new set images appear without a restart. - Autocomplete returns no suggestions until 2+ characters are typed (0-1 chars only yields arbitrary names). feat(card-bot): name+mana header line, symbolize oracle/labels, meter above tree fix(card-bot): blank line between coverage meter and breakdown --- deploy/card-bot-push.sh | 60 ++++++++ deploy/card-bot.Dockerfile | 22 +++ deploy/phase-server.nginx.conf | 10 ++ scripts/card-bot/config.ts | 87 ++++++++++++ scripts/card-bot/coverageData.ts | 225 +++++++++++++++++++++++++++++ scripts/card-bot/discord.ts | 137 ++++++++++++++++++ scripts/card-bot/index.ts | 160 +++++++++++++++++++++ scripts/card-bot/manaEmoji.json | 84 +++++++++++ scripts/card-bot/register.ts | 34 +++++ scripts/card-bot/render.ts | 236 +++++++++++++++++++++++++++++++ scripts/card-bot/scryfall.ts | 119 ++++++++++++++++ scripts/card-bot/smoke.ts | 33 +++++ scripts/card-bot/upload-emoji.ts | 91 ++++++++++++ 13 files changed, 1298 insertions(+) create mode 100755 deploy/card-bot-push.sh create mode 100644 deploy/card-bot.Dockerfile create mode 100644 scripts/card-bot/config.ts create mode 100644 scripts/card-bot/coverageData.ts create mode 100644 scripts/card-bot/discord.ts create mode 100644 scripts/card-bot/index.ts create mode 100644 scripts/card-bot/manaEmoji.json create mode 100644 scripts/card-bot/register.ts create mode 100644 scripts/card-bot/render.ts create mode 100644 scripts/card-bot/scryfall.ts create mode 100644 scripts/card-bot/smoke.ts create mode 100644 scripts/card-bot/upload-emoji.ts diff --git a/deploy/card-bot-push.sh b/deploy/card-bot-push.sh new file mode 100755 index 0000000000..b98e9f114c --- /dev/null +++ b/deploy/card-bot-push.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build the card-bot image locally, ship it to the VPS over SSH, and (re)start +# the container. Mirrors deploy/push.sh (phase-server), but auto-detects how the +# SSH user reaches docker: directly if it can, else via `sudo docker`. (A fresh +# setup-vps.sh box grants passwordless `sudo docker`; this box puts the deploy +# user in the docker group instead.) +# +# The long-lived container needs NO secrets: the public key is baked into +# config.ts and signature follow-ups use the per-interaction webhook token. The +# bot token is only needed to (re)register the slash command — a one-off +# `docker run` reading an env file on the host (app id / guild id / public key +# are all baked-in defaults, so the token is all that's required): +# /etc/phase-card-bot.env → CARD_BOT_TOKEN (secret, from the Discord portal) +# +# Usage: ./deploy/card-bot-push.sh (HOST defaults to the phase-vps ssh alias) + +HOST="${CARD_BOT_HOST:-phase-vps}" +IMAGE="phase-card-bot:local" +ENV_FILE="/etc/phase-card-bot.env" + +# Remote prelude: choose `docker` vs `sudo docker` for this SSH user. +detect='D=docker; docker info >/dev/null 2>&1 || D="sudo docker";' + +wait_for_health_remote='for _ in $(seq 1 30); do + if curl -fsS http://127.0.0.1:9375/health >/dev/null; then healthy=1; break; fi + sleep 1 +done +if [ "${healthy:-0}" != "1" ]; then + $D logs --tail 50 phase-card-bot || true + exit 1 +fi' + +echo "Building ${IMAGE}..." +# --platform linux/amd64: the VPS is x86_64 even when building from Apple Silicon. +# --provenance=false keeps the image in the classic format the host's older +# Docker (20.10.x) can `docker load`. +docker buildx build --platform linux/amd64 --provenance=false --load \ + -f deploy/card-bot.Dockerfile -t "$IMAGE" scripts/card-bot + +echo "Uploading image to ${HOST}..." +docker save "$IMAGE" | ssh "${HOST}" "${detect} \$D load" + +echo "Deploying..." +ssh "${HOST}" "${detect} \ + (\$D stop phase-card-bot || true) \ + && (\$D rm phase-card-bot || true) \ + && \$D run -d \ + --name phase-card-bot \ + --restart unless-stopped \ + -p 127.0.0.1:9375:9375 \ + ${IMAGE} \ + && echo 'Waiting for health...' \ + && ${wait_for_health_remote} \ + && \$D ps --filter name=phase-card-bot --filter status=running" + +echo "Done — phase-card-bot deployed to ${HOST}" +echo "If the /card command shape changed, register it once with:" +echo " ssh ${HOST} \"${detect} \\\$D run --rm --env-file ${ENV_FILE} ${IMAGE} bun run card-bot/register.ts\"" diff --git a/deploy/card-bot.Dockerfile b/deploy/card-bot.Dockerfile new file mode 100644 index 0000000000..7311129c8d --- /dev/null +++ b/deploy/card-bot.Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 +# +# Discord /card parse-breakdown bot. Dependency-free Bun service — it fetches +# coverage-data.json from R2 and card images from Scryfall at runtime, so no +# data or npm packages are baked in. Build context is scripts/card-bot/: +# docker build -f deploy/card-bot.Dockerfile -t phase-card-bot scripts/card-bot + +FROM oven/bun:1.3-alpine AS runtime + +WORKDIR /app +COPY --chown=bun:bun . ./card-bot + +ENV CARD_BOT_PORT=9375 +EXPOSE 9375 + +USER bun + +# Generous start period: the first request warms the ~52MB coverage export. +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD wget -qO- "http://127.0.0.1:${CARD_BOT_PORT}/health" >/dev/null 2>&1 || exit 1 + +CMD ["bun", "run", "card-bot/index.ts"] diff --git a/deploy/phase-server.nginx.conf b/deploy/phase-server.nginx.conf index 76b0eab4c8..15a4797cc5 100644 --- a/deploy/phase-server.nginx.conf +++ b/deploy/phase-server.nginx.conf @@ -18,6 +18,16 @@ server { proxy_send_timeout 3600s; } + # Discord card-bot (separate container on :9375). The trailing slash on + # proxy_pass strips the /card-bot/ prefix, so Discord's configured endpoint + # https:///card-bot/interactions reaches the bot as POST /interactions. + location /card-bot/ { + proxy_pass http://127.0.0.1:9375/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + location / { proxy_pass http://phase_server; proxy_set_header Host $host; diff --git a/scripts/card-bot/config.ts b/scripts/card-bot/config.ts new file mode 100644 index 0000000000..77e58f8535 --- /dev/null +++ b/scripts/card-bot/config.ts @@ -0,0 +1,87 @@ +// Card-bot configuration. Dependency-free: reads everything from Bun.env, +// mirroring the rest of scripts/ (no package.json, no node_modules). + +/** + * Which R2-published build to read parse data from. + * - `release` → bucket root (https:///coverage-data.json) + * - `preview` → the preview/staging site's data (https:///staging/coverage-data.json) + * + * These map to the same paths the deployed frontends read (release.yml → root, + * deploy.yml staging → /staging). The Scryfall image is build-independent. + */ +export type Build = "release" | "preview"; + +export const BUILDS: readonly Build[] = ["release", "preview"] as const; + +export function isBuild(value: string): value is Build { + return (BUILDS as readonly string[]).includes(value); +} + +/** Public R2 base, overridable for self-host / testing. No trailing slash. */ +const R2_BASE = ( + Bun.env.CARD_BOT_R2_BASE ?? "https://pub-fc5b5c2c6e774356ae3e730bb0326394.r2.dev" +).replace(/\/+$/, ""); + +/** Per-build path prefix under the R2 base. */ +const BUILD_PREFIX: Record = { + release: "", + preview: "/staging", +}; + +/** URL of the coverage export (carries per-card `parse_details`) for a build. */ +export function coverageUrl(build: Build): string { + return `${R2_BASE}${BUILD_PREFIX[build]}/coverage-data.json`; +} + +/** URL of the tiny build-provenance manifest (commit, mtgjson date) for a build. */ +export function metaUrl(build: Build): string { + return `${R2_BASE}${BUILD_PREFIX[build]}/card-data-meta.json`; +} + +/** + * URL of the Scryfall image/metadata export — the same file the app reads + * (services/scryfall.ts). Build-independent (it's Scryfall bulk data, not engine + * output), so we just read the freshest copy from the default build's path. + */ +export function scryfallDataUrl(): string { + return `${R2_BASE}${BUILD_PREFIX[DEFAULT_BUILD]}/scryfall-data.json`; +} + +/** Default build when the user omits the option — preview tracks latest main. */ +export const DEFAULT_BUILD: Build = "preview"; + +function required(name: string): string { + const value = Bun.env[name]; + if (!value) throw new Error(`Missing required env var ${name}`); + return value; +} + +// Public identifiers for the dedicated card-bot Discord app. Both are non-secret +// (the public key exists to be shared for signature verification; the app id +// appears in every interaction), so they're baked in as overridable defaults — +// only the bot token and guild id need to be supplied per host. +const DEFAULT_APP_ID = "1508547877331271892"; +const DEFAULT_PUBLIC_KEY = + "7cde1d5e7a717be0d222f93526e40aeb17165e02521a2471e5b6794e2e0f328e"; +// The phase.rs community server. Non-secret (a guild id is visible to members); +// baked in so registration needs no config beyond the bot token. +const DEFAULT_GUILD_ID = "1485498006781427802"; + +/** Discord application credentials (the dedicated card-bot app, not the bug bot). */ +export const discord = { + /** Bot token (secret) — only needed to register slash commands (register.ts). */ + token: () => required("CARD_BOT_TOKEN"), + /** Ed25519 public key — verifies inbound interaction signatures. */ + publicKey: () => Bun.env.CARD_BOT_PUBLIC_KEY || DEFAULT_PUBLIC_KEY, + /** Application (client) id. */ + appId: () => Bun.env.CARD_BOT_APP_ID || DEFAULT_APP_ID, + /** Guild to register the command in (instant propagation, single-server bot). */ + guildId: () => Bun.env.CARD_BOT_GUILD_ID || DEFAULT_GUILD_ID, +}; + +/** HTTP port the interactions server listens on (behind nginx on 127.0.0.1). */ +export const PORT = Number(Bun.env.CARD_BOT_PORT ?? 9375); + +/** Identifies the bot to Scryfall per their API etiquette. */ +export const SCRYFALL_USER_AGENT = + Bun.env.CARD_BOT_USER_AGENT ?? "phase-rs-card-bot/1.0 (+https://phase-rs.dev)"; diff --git a/scripts/card-bot/coverageData.ts b/scripts/card-bot/coverageData.ts new file mode 100644 index 0000000000..32b099edbc --- /dev/null +++ b/scripts/card-bot/coverageData.ts @@ -0,0 +1,225 @@ +// Per-build cache of the engine's coverage export (coverage-data.json on R2). +// +// Each card entry carries `parse_details` — the exact `ParsedItem` tree the +// frontend's Alt-hover "ENGINE PARSE" overlay renders (engine-authoritative, +// built by crates/engine/src/game/coverage.rs). We read it straight from R2 so +// the bot never re-derives parse logic and always reflects the deployed build. +// +// Memory: the file is ~52MB. We parse it once per (re)load, then keep each card +// as a compact JSON string keyed by lowercased name — a lookup parses one small +// entry on demand. This avoids holding a 34k-node live object graph resident +// (the OOM shape noted in project memory) while keeping steady RAM ≈ file size. + +import { + type Build, + DEFAULT_BUILD, + coverageUrl, + metaUrl, +} from "./config"; + +/** A node in the engine's hierarchical parse tree. Mirrors Rust `ParsedItem`. */ +export interface ParsedItem { + category: "keyword" | "ability" | "trigger" | "static" | "replacement" | "cost"; + label: string; + source_text?: string | null; + supported: boolean; + details?: [string, string][]; + children?: ParsedItem[]; +} + +/** A single card's entry in coverage-data.json `cards[]`. */ +export interface CoverageEntry { + card_name: string; + set_code?: string; + oracle_text?: string | null; + supported: boolean; + gap_count: number; + gap_details?: { handler: string; source_text: string }[]; + parse_details: ParsedItem[]; + printings?: string[]; +} + +/** Build provenance from card-data-meta.json (shown in the embed footer). */ +export interface BuildMeta { + commit_short?: string; + mtgjson_date?: string; + data_hash?: string; + generated_at?: string; +} + +interface BuildCache { + /** lowercased card name → JSON.stringify(CoverageEntry). */ + byName: Map; + /** Display-cased names, sorted, for autocomplete. */ + names: string[]; + meta: BuildMeta | null; + etag: string | null; + lastAccess: number; + checkedAt: number; +} + +/** How long a cached build serves before a background revalidation is kicked off. */ +const REFRESH_MS = 5 * 60 * 1000; +/** Idle non-default builds are evicted after this long without access. */ +const IDLE_MS = 30 * 60 * 1000; + +const caches = new Map(); +const loading = new Map>(); + +async function fetchMeta(build: Build): Promise { + try { + const res = await fetch(metaUrl(build), { headers: { "cache-control": "no-cache" } }); + if (!res.ok) return null; + return (await res.json()) as BuildMeta; + } catch { + return null; + } +} + +interface FetchedCoverage { + byName: Map; + names: string[]; + etag: string | null; +} + +/** + * Fetches and indexes coverage-data.json. With `prevEtag`, sends a conditional + * request; returns null on 304 (caller keeps its existing index). + */ +async function fetchCoverage( + build: Build, + prevEtag: string | null, +): Promise { + const res = await fetch(coverageUrl(build), { + headers: prevEtag ? { "If-None-Match": prevEtag } : {}, + }); + if (res.status === 304) return null; + if (!res.ok) { + throw new Error(`coverage fetch ${build} → ${res.status} ${res.statusText}`); + } + + const etag = res.headers.get("etag"); + const json = (await res.json()) as { cards?: CoverageEntry[] }; + const cards = json.cards ?? []; + + const byName = new Map(); + const names: string[] = []; + for (const entry of cards) { + if (!entry?.card_name) continue; + byName.set(entry.card_name.toLowerCase(), JSON.stringify(entry)); + names.push(entry.card_name); + } + names.sort((a, b) => a.localeCompare(b)); + return { byName, names, etag }; +} + +async function loadBuild(build: Build): Promise { + const [fetched, meta] = await Promise.all([fetchCoverage(build, null), fetchMeta(build)]); + // fetched is non-null here: no prevEtag means no 304 path. + const now = Date.now(); + const cache: BuildCache = { + byName: fetched!.byName, + names: fetched!.names, + meta, + etag: fetched!.etag, + lastAccess: now, + checkedAt: now, + }; + caches.set(build, cache); + console.log(`[coverage] loaded ${build}: ${cache.byName.size} cards`); + return cache; +} + +/** Stale-while-revalidate: refresh an already-loaded build in the background. */ +async function revalidate(build: Build): Promise { + const cache = caches.get(build); + if (!cache) return; + cache.checkedAt = Date.now(); // mark attempt now so we don't stack revalidations + const [fetched, meta] = await Promise.all([ + fetchCoverage(build, cache.etag), + fetchMeta(build), + ]); + if (fetched) { + cache.byName = fetched.byName; + cache.names = fetched.names; + cache.etag = fetched.etag; + } + if (meta) cache.meta = meta; +} + +/** + * Returns a ready build cache. Serves a loaded build immediately (kicking off a + * background revalidation when stale); otherwise loads it, deduping concurrent + * callers so the 52MB fetch happens once. + */ +async function ensureBuild(build: Build): Promise { + const existing = caches.get(build); + if (existing) { + existing.lastAccess = Date.now(); + if (Date.now() - existing.checkedAt > REFRESH_MS) { + void revalidate(build).catch(() => {}); + } + return existing; + } + + let pending = loading.get(build); + if (!pending) { + pending = loadBuild(build); + loading.set(build, pending); + void pending.finally(() => loading.delete(build)); + } + return pending; +} + +/** Looks up one card's parsed coverage entry, or null if unknown. */ +export async function lookupCard( + build: Build, + name: string, +): Promise { + const cache = await ensureBuild(build); + const raw = cache.byName.get(name.toLowerCase()); + return raw ? (JSON.parse(raw) as CoverageEntry) : null; +} + +/** Returns the build's provenance manifest (may be null if unavailable). */ +export async function getMeta(build: Build): Promise { + const cache = await ensureBuild(build); + return cache.meta; +} + +/** Autocomplete: up to `limit` card names matching `query` (prefix first). */ +export async function suggestNames( + build: Build, + query: string, + limit = 25, +): Promise { + const cache = await ensureBuild(build); + const q = query.trim().toLowerCase(); + if (!q) return cache.names.slice(0, limit); + + const prefix: string[] = []; + const contains: string[] = []; + for (const name of cache.names) { + const lower = name.toLowerCase(); + if (lower.startsWith(q)) prefix.push(name); + else if (lower.includes(q)) contains.push(name); + if (prefix.length >= limit) break; + } + return [...prefix, ...contains].slice(0, limit); +} + +/** Pre-loads the default build so the common path never pays a cold-fetch. */ +export async function warmDefaultBuild(): Promise { + await ensureBuild(DEFAULT_BUILD); +} + +/** Periodically drops idle non-default builds to bound memory. */ +export function startEvictionLoop(): ReturnType { + return setInterval(() => { + const now = Date.now(); + for (const [build, cache] of caches) { + if (build === DEFAULT_BUILD) continue; + if (now - cache.lastAccess > IDLE_MS) caches.delete(build); + } + }, IDLE_MS); +} diff --git a/scripts/card-bot/discord.ts b/scripts/card-bot/discord.ts new file mode 100644 index 0000000000..8e28cdffdb --- /dev/null +++ b/scripts/card-bot/discord.ts @@ -0,0 +1,137 @@ +// Minimal Discord interactions plumbing, dependency-free (matches scripts/). +// Ed25519 verification uses Bun's WebCrypto; REST uses fetch. + +const API = "https://discord.com/api/v10"; + +/** Interaction request types (Discord `InteractionType`). */ +export const InteractionType = { + PING: 1, + APPLICATION_COMMAND: 2, + APPLICATION_COMMAND_AUTOCOMPLETE: 4, +} as const; + +/** Interaction response types (Discord `InteractionResponseType`). */ +export const ResponseType = { + PONG: 1, + CHANNEL_MESSAGE_WITH_SOURCE: 4, + DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE: 5, + APPLICATION_COMMAND_AUTOCOMPLETE_RESULT: 8, +} as const; + +/** Slash-command option types we use. */ +export const OptionType = { + STRING: 3, +} as const; + +export interface InteractionOption { + name: string; + type: number; + value?: string | number | boolean; + focused?: boolean; +} + +export interface Interaction { + type: number; + application_id: string; + token: string; + data?: { + name: string; + options?: InteractionOption[]; + }; +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +const keyCache = new Map>(); +function importKey(publicKeyHex: string): Promise { + let key = keyCache.get(publicKeyHex); + if (!key) { + key = crypto.subtle.importKey( + "raw", + hexToBytes(publicKeyHex), + { name: "Ed25519" }, + false, + ["verify"], + ); + keyCache.set(publicKeyHex, key); + } + return key; +} + +/** + * Verifies a Discord interaction request signature over `timestamp + rawBody`. + * Returns false on any malformed input — never throws. + */ +export async function verifyRequest( + publicKeyHex: string, + signatureHex: string | null, + timestamp: string | null, + rawBody: string, +): Promise { + if (!signatureHex || !timestamp) return false; + try { + const key = await importKey(publicKeyHex); + const message = new TextEncoder().encode(timestamp + rawBody); + return await crypto.subtle.verify( + { name: "Ed25519" }, + key, + hexToBytes(signatureHex), + message, + ); + } catch { + return false; + } +} + +/** Bulk-overwrites the guild's command set (instant propagation). */ +export async function registerGuildCommand( + appId: string, + guildId: string, + token: string, + command: unknown, +): Promise { + const res = await fetch(`${API}/applications/${appId}/guilds/${guildId}/commands`, { + method: "PUT", + headers: { + Authorization: `Bot ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify([command]), + }); + if (!res.ok) { + throw new Error(`registerGuildCommand → ${res.status}: ${await res.text()}`); + } +} + +/** Edits the original (deferred) interaction response with the final content. */ +export async function editOriginalResponse( + appId: string, + interactionToken: string, + body: unknown, +): Promise { + const url = `${API}/webhooks/${appId}/${interactionToken}/messages/@original`; + for (let attempt = 0; attempt < 3; attempt++) { + const res = await fetch(url, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10000), + }); + if (res.status === 429) { + const { retry_after } = (await res.json()) as { retry_after: number }; + await Bun.sleep(Math.ceil(retry_after * 1000) + 100); + continue; + } + if (!res.ok) { + throw new Error(`editOriginalResponse → ${res.status}: ${await res.text()}`); + } + return; + } + throw new Error("editOriginalResponse → exhausted retries"); +} diff --git a/scripts/card-bot/index.ts b/scripts/card-bot/index.ts new file mode 100644 index 0000000000..b0fd11b13e --- /dev/null +++ b/scripts/card-bot/index.ts @@ -0,0 +1,160 @@ +// Discord HTTP-interactions server for the /card parse-breakdown bot. +// +// Flow: Discord POSTs each interaction here (behind nginx on 127.0.0.1). We +// verify the Ed25519 signature, then: +// • PING → PONG +// • /card → defer, then follow up with the parse embed +// • autocomplete → name suggestions from the (warm) default build +// +// Deferring the command guarantees we never hit Discord's 3s response window, +// even on a cold preview load or a slow Scryfall call. + +import { + DEFAULT_BUILD, + PORT, + discord, + isBuild, + type Build, +} from "./config"; +import { + getMeta, + lookupCard, + startEvictionLoop, + suggestNames, + warmDefaultBuild, +} from "./coverageData"; +import { + type Interaction, + type InteractionOption, + InteractionType, + ResponseType, + editOriginalResponse, + verifyRequest, +} from "./discord"; +import { renderCardEmbed, renderNotFound } from "./render"; +import { lookupScryfall, warmScryfall } from "./scryfall"; + +const PUBLIC_KEY = discord.publicKey(); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function optionValue(options: InteractionOption[] | undefined, name: string): string | undefined { + const opt = options?.find((o) => o.name === name); + return typeof opt?.value === "string" ? opt.value : undefined; +} + +function resolveBuild(raw: string | undefined): Build { + return raw && isBuild(raw) ? raw : DEFAULT_BUILD; +} + +/** Builds and delivers the parse embed for a deferred /card interaction. */ +async function deliverCard(interaction: Interaction): Promise { + const options = interaction.data?.options; + const name = optionValue(options, "name")?.trim() ?? ""; + const build = resolveBuild(optionValue(options, "build")); + + const t0 = performance.now(); + try { + const entry = await lookupCard(build, name); + const t1 = performance.now(); + if (!entry) { + await editOriginalResponse(interaction.application_id, interaction.token, { + embeds: [renderNotFound(name, build)], + }); + return; + } + + const [scry, meta] = await Promise.all([ + lookupScryfall(entry.card_name), + getMeta(build), + ]); + const tScry = performance.now(); + await editOriginalResponse(interaction.application_id, interaction.token, { + embeds: [renderCardEmbed(entry, scry, build, meta)], + }); + const t2 = performance.now(); + console.log( + `[card] ${name} (${build}): lookup=${Math.round(t1 - t0)}ms scry+meta=${Math.round(tScry - t1)}ms send=${Math.round(t2 - tScry)}ms total=${Math.round(t2 - t0)}ms`, + ); + } catch (err) { + console.error(`deliverCard(${name}, ${build}) failed:`, err); + await editOriginalResponse(interaction.application_id, interaction.token, { + content: `Something went wrong looking up **${name}**. Try again in a moment.`, + }).catch(() => {}); + } +} + +/** Suggestions only begin once this many characters are typed. */ +const MIN_AUTOCOMPLETE_CHARS = 2; + +/** Synchronous autocomplete: suggest names from the warm default build. */ +async function autocomplete(interaction: Interaction): Promise { + const focused = interaction.data?.options?.find((o) => o.focused); + const query = typeof focused?.value === "string" ? focused.value : ""; + // Hold off until a couple of characters are typed — the lookup is in-memory + // and cheap, but 0–1 chars just returns arbitrary names, not useful matches. + const choices = + query.trim().length < MIN_AUTOCOMPLETE_CHARS + ? [] + : (await suggestNames(DEFAULT_BUILD, query)).map((n) => ({ name: n, value: n })); + return json({ + type: ResponseType.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT, + data: { choices }, + }); +} + +async function handleInteraction(req: Request): Promise { + const signature = req.headers.get("X-Signature-Ed25519"); + const timestamp = req.headers.get("X-Signature-Timestamp"); + const rawBody = await req.text(); + + if (!(await verifyRequest(PUBLIC_KEY, signature, timestamp, rawBody))) { + return new Response("invalid request signature", { status: 401 }); + } + + const interaction = JSON.parse(rawBody) as Interaction; + + if (interaction.type === InteractionType.PING) { + return json({ type: ResponseType.PONG }); + } + + if (interaction.type === InteractionType.APPLICATION_COMMAND_AUTOCOMPLETE) { + return autocomplete(interaction); + } + + if (interaction.type === InteractionType.APPLICATION_COMMAND) { + // Defer immediately; the follow-up edit carries the embed. + void deliverCard(interaction); + return json({ type: ResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE }); + } + + return json({ error: "unsupported interaction type" }, 400); +} + +// Warm the default build in the BACKGROUND, and start serving immediately so a +// restart has no closed-port window. A query that lands mid-warm dedupes onto +// the in-flight load (the deferred response covers the wait) instead of failing. +void warmDefaultBuild().catch((err) => console.error("warm-up failed:", err)); +void warmScryfall().catch((err) => console.error("scryfall warm-up failed:", err)); +startEvictionLoop(); + +Bun.serve({ + port: PORT, + async fetch(req) { + const { pathname } = new URL(req.url); + if (req.method === "GET" && pathname === "/health") { + return new Response("ok"); + } + if (req.method === "POST") { + return handleInteraction(req); + } + return new Response("not found", { status: 404 }); + }, +}); + +console.log(`card-bot listening on :${PORT} (default build: ${DEFAULT_BUILD})`); diff --git a/scripts/card-bot/manaEmoji.json b/scripts/card-bot/manaEmoji.json new file mode 100644 index 0000000000..1e84b23abc --- /dev/null +++ b/scripts/card-bot/manaEmoji.json @@ -0,0 +1,84 @@ +{ + "{T}": "<:manat:1508554369619071106>", + "{Q}": "<:manaq:1508554371993047202>", + "{E}": "<:manae:1508554374245253241>", + "{P}": "<:manap:1508554376317370509>", + "{PW}": "<:manapw:1508554378427109509>", + "{CHAOS}": "<:manachaos:1508554380620726304>", + "{A}": "<:manaa:1508554382923534530>", + "{TK}": "<:manatk:1508554385825992934>", + "{X}": "<:manax:1508554387910295722>", + "{Y}": "<:manay:1508554390024355980>", + "{Z}": "<:manaz:1508554392373035028>", + "{0}": "<:mana0:1508554394566791370>", + "{1}": "<:mana1:1508554396303228989>", + "{2}": "<:mana2:1508554398920474775>", + "{3}": "<:mana3:1508554401445445644>", + "{4}": "<:mana4:1508554404041850991>", + "{5}": "<:mana5:1508554406138871948>", + "{6}": "<:mana6:1508554408340754493>", + "{7}": "<:mana7:1508554410765320324>", + "{8}": "<:mana8:1508554413206274209>", + "{9}": "<:mana9:1508554415328723096>", + "{10}": "<:mana10:1508554417622880267>", + "{11}": "<:mana11:1508554419787010120>", + "{12}": "<:mana12:1508554422123364473>", + "{13}": "<:mana13:1508554424375836934>", + "{14}": "<:mana14:1508554426426720269>", + "{15}": "<:mana15:1508554428700168385>", + "{16}": "<:mana16:1508554430826676344>", + "{17}": "<:mana17:1508554433007714437>", + "{18}": "<:mana18:1508554435360592134>", + "{19}": "<:mana19:1508554437600215243>", + "{20}": "<:mana20:1508554439588315320>", + "{100}": "<:mana100:1508554441840660674>", + "{1000000}": "<:mana1000000:1508554443816308889>", + "{W/U}": "<:manawu:1508554446622167124>", + "{W/B}": "<:manawb:1508554449281355826>", + "{B/R}": "<:manabr:1508554451613646890>", + "{B/G}": "<:manabg:1508554453589033003>", + "{U/B}": "<:manaub:1508554456500011008>", + "{U/R}": "<:manaur:1508554458341052618>", + "{R/G}": "<:manarg:1508554460836663468>", + "{R/W}": "<:manarw:1508554462862512330>", + "{G/W}": "<:managw:1508554465941258241>", + "{G/U}": "<:managu:1508554468441198602>", + "{B/G/P}": "<:manabgp:1508554470647398531>", + "{B/R/P}": "<:manabrp:1508554472744423474>", + "{G/U/P}": "<:managup:1508554474988376105>", + "{G/W/P}": "<:managwp:1508554477085392927>", + "{R/G/P}": "<:manargp:1508554478977159393>", + "{R/W/P}": "<:manarwp:1508554481493606560>", + "{U/B/P}": "<:manaubp:1508554483716853880>", + "{U/R/P}": "<:manaurp:1508554485704687728>", + "{W/B/P}": "<:manawbp:1508554488112484503>", + "{W/U/P}": "<:manawup:1508554490532597831>", + "{C/W}": "<:manacw:1508554492801450125>", + "{C/U}": "<:manacu:1508554494945005618>", + "{C/B}": "<:manacb:1508554496899420403>", + "{C/R}": "<:manacr:1508554499160150141>", + "{C/G}": "<:manacg:1508554501601104043>", + "{2/W}": "<:mana2w:1508554503643987979>", + "{2/U}": "<:mana2u:1508554505619378187>", + "{2/B}": "<:mana2b:1508554508106600478>", + "{2/R}": "<:mana2r:1508554510409269270>", + "{2/G}": "<:mana2g:1508554512690843872>", + "{H}": "<:manah:1508554514905567303>", + "{W/P}": "<:manawp:1508554517149646888>", + "{U/P}": "<:manaup:1508554518907064483>", + "{B/P}": "<:manabp:1508554521591283783>", + "{R/P}": "<:manarp:1508554523453686013>", + "{G/P}": "<:managp:1508554526003564544>", + "{C/P}": "<:manacp:1508554528092328047>", + "{HW}": "<:manahw:1508554530294468708>", + "{HR}": "<:manahr:1508554532538548376>", + "{W}": "<:manaw:1508554534690230436>", + "{U}": "<:manau:1508554537047162890>", + "{B}": "<:manab:1508554539089793196>", + "{R}": "<:manar:1508554541799440424>", + "{G}": "<:manag:1508554544169357492>", + "{C}": "<:manac:1508554546178293981>", + "{S}": "<:manas:1508554548506263783>", + "{L}": "<:manal:1508554550691238010>", + "{D}": "<:manad:1508554553081987143>" +} diff --git a/scripts/card-bot/register.ts b/scripts/card-bot/register.ts new file mode 100644 index 0000000000..6489511c35 --- /dev/null +++ b/scripts/card-bot/register.ts @@ -0,0 +1,34 @@ +// One-time (re)registration of the /card guild command. Run after changing the +// command shape: `bun scripts/card-bot/register.ts`. +// +// Guild-scoped → instant propagation on the single community server. + +import { discord } from "./config"; +import { OptionType, registerGuildCommand } from "./discord"; + +const command = { + name: "card", + description: "Show how the phase.rs engine parses a card", + options: [ + { + type: OptionType.STRING, + name: "name", + description: "Card name", + required: true, + autocomplete: true, + }, + { + type: OptionType.STRING, + name: "build", + description: "Which build's parse data to read (default: preview)", + required: false, + choices: [ + { name: "preview", value: "preview" }, + { name: "release", value: "release" }, + ], + }, + ], +}; + +await registerGuildCommand(discord.appId(), discord.guildId(), discord.token(), command); +console.log("Registered /card guild command."); diff --git a/scripts/card-bot/render.ts b/scripts/card-bot/render.ts new file mode 100644 index 0000000000..c182e91cd0 --- /dev/null +++ b/scripts/card-bot/render.ts @@ -0,0 +1,236 @@ +// Renders a card's engine parse into a Discord embed that echoes the frontend's +// Alt-hover "ENGINE PARSE" overlay (client/src/components/card/CardPreview.tsx). +// +// Markdown layout (not a code block): the card's oracle text shows once as a `>` +// blockquote, then a compact one-line-per-item tree. Color comes from emoji — +// category squares (overlay palette), mana pips, the coverage meter — because +// Discord has no theme-adaptive text color (only ANSI, which is theme-fragile). + +import type { Build } from "./config"; +import type { BuildMeta, CoverageEntry, ParsedItem } from "./coverageData"; +import manaEmojiRaw from "./manaEmoji.json"; +import type { ScryfallCard } from "./scryfall"; + +// symbol ("{R}") → custom application-emoji markup ("<:manar:id>"). Populated by +// upload-emoji.ts; empty until then, in which case mana falls back to unicode. +const MANA_EMOJI = manaEmojiRaw as Record; + +// Category → abbreviation + a colored square matching the overlay palette +// (violet/sky/amber/teal/orange/rose). Unicode emoji render in color in any +// theme — unlike ANSI or custom <:emoji:> (which don't render in markdown spots). +const CATEGORY: Record = { + keyword: { abbr: "KW", square: "🟪" }, + ability: { abbr: "EFF", square: "🟦" }, + trigger: { abbr: "TRG", square: "🟨" }, + static: { abbr: "STC", square: "🟩" }, + replacement: { abbr: "RPL", square: "🟧" }, + cost: { abbr: "CST", square: "🟥" }, +}; + +// Emerald / amber, matching the overlay's support colors. +const COLOR_OK = 0x10b981; +const COLOR_GAPS = 0xf59e0b; + +const DESCRIPTION_MAX = 4096; +const DETAIL_VALUE_MAX = 120; +const SOURCE_TEXT_MAX = 160; +const ORACLE_MAX = 600; +const EM = "\u2003"; // em space (regular spaces collapse in markdown) + +const truncate = (s: string, max: number) => + s.length > max ? `${s.slice(0, max - 1)}…` : s; + +// MTG mana → emoji pips. `replace` (not match+join) preserves separators like the +// " // " between split/adventure faces. Generic costs stay plain digits; hybrid / +// Phyrexian / X fall back to {…} when not uploaded. +const COLOR_PIP: Record = { + W: "⚪", + U: "🔵", + B: "⚫", + R: "🔴", + G: "🟢", + C: "◇", + S: "❄️", +}; + +// Replaces every `{…}` symbol token in arbitrary text with its emoji — works on +// mana costs, oracle text ("{T}: Add {G}"), and quoted fragments alike. Safe to +// run anywhere markdown emoji render (description, `>` blockquotes), but NOT +// inside inline-code/```pre``` spans, where Discord shows the literal markup. +function symbolize(text: string): string { + return text.replace(/\{[^}]+\}/g, (t) => { + if (MANA_EMOJI[t]) return MANA_EMOJI[t]; // true pip (incl. tap/hybrid/Phyrexian) + const sym = t.slice(1, -1); + if (COLOR_PIP[sym]) return COLOR_PIP[sym]; // unicode fallback + if (/^\d+$/.test(sym)) return sym; + return t; + }); +} + +const formatMana = (cost: string | null): string | null => (cost ? symbolize(cost) : null); + +// A 10-segment coverage bar echoing the Alt overlay's emerald/amber meter. +function supportMeter(supported: number, total: number): string { + const segs = 10; + const filled = + total === 0 ? segs : Math.max(0, Math.min(segs, Math.round((supported / total) * segs))); + return "🟩".repeat(filled) + "🟧".repeat(segs - filled); +} + +interface Counts { + supported: number; + total: number; +} + +/** Recursively counts supported vs total nodes (the overlay's "3/5" fraction). */ +function countItems(items: ParsedItem[], acc: Counts = { supported: 0, total: 0 }): Counts { + for (const item of items) { + acc.total += 1; + if (item.supported) acc.supported += 1; + if (item.children?.length) countItems(item.children, acc); + } + return acc; +} + +/** The card's oracle text as a `>` blockquote, with `{…}` symbols as emoji. */ +function oracleQuote(text: string): string { + return symbolize(truncate(text.trim(), ORACLE_MAX)) + .split("\n") + .map((l) => `> ${l}`) + .join("\n"); +} + +/** One compact markdown line per parse item, em-space indented by depth. */ +function treeLines(items: ParsedItem[], depth = 0, out: string[] = []): string[] { + for (const item of items) { + const indent = EM.repeat(depth); + const cat = CATEGORY[item.category]; + if (item.supported) { + let line = `${indent}${cat.square} **${cat.abbr}** ${symbolize(item.label)}`; + if (item.details?.length) { + // Detail values stay in inline-code chips — emoji can't render there, so + // these keep the raw `{…}` text (intentional: code is the "verbatim" lane). + const detail = item.details + .map(([k, v]) => `${k} \`${truncate(v, DETAIL_VALUE_MAX)}\``) + .join(" · "); + line += ` · ${detail}`; + } + out.push(line); + } else { + // Unsupported lines read clearly as "engine couldn't handle this": a + // leading ❌ (distinct from the category squares) and a struck-through + // label, with the unparsed Oracle fragment quoted (symbols → emoji). + let line = `${indent}❌ **${cat.abbr}** ~~${symbolize(item.label)}~~`; + if (item.source_text) + line += ` — “${symbolize(truncate(item.source_text, SOURCE_TEXT_MAX))}”`; + out.push(line); + } + if (item.children?.length) treeLines(item.children, depth + 1, out); + } + return out; +} + +/** Joins tree lines within the description budget, noting any dropped tail. */ +function fitLines(lines: string[], budget: number): string { + const kept: string[] = []; + let used = 0; + let dropped = 0; + for (const line of lines) { + if (used + line.length + 1 > budget) { + dropped = lines.length - kept.length; + break; + } + kept.push(line); + used += line.length + 1; + } + let body = kept.join("\n"); + if (dropped > 0) body += `\n-# … ${dropped} more line(s) — see in-app`; + return body; +} + +/** + * The header block, top of the description: + * line 1 — **Card Name** (linked to Scryfall) · mana cost + * line 2 — **Type line** + * then — oracle text as a `>` blockquote + * + * The name lives here (not the embed `title`) so the mana emoji can sit beside + * it — custom emoji don't render in an embed title, only in description text. + */ +function cardHeader(entry: CoverageEntry, scry: ScryfallCard | null): string { + const link = scry?.scryfallUri; + const name = `**${entry.card_name}**`; + const mana = formatMana(scry?.manaCost ?? null); + + const lines: string[] = []; + lines.push(mana ? `${link ? `[${name}](${link})` : name} ${mana}` : link ? `[${name}](${link})` : name); + if (scry?.typeLine) lines.push(`**${scry.typeLine}**`); + if (entry.oracle_text) lines.push(oracleQuote(entry.oracle_text)); + return lines.join("\n"); +} + +function footerText(build: Build, meta: BuildMeta | null): string { + const bits = [build.toUpperCase()]; + if (meta?.commit_short) bits.push(meta.commit_short); + if (meta?.mtgjson_date) bits.push(`MTGJSON ${meta.mtgjson_date}`); + return bits.join(" · "); +} + +/** A Discord embed object (the subset we populate). */ +export interface Embed { + author?: { name: string }; + title?: string; + url?: string; + description?: string; + color?: number; + thumbnail?: { url: string }; + footer?: { text: string }; +} + +/** Builds the parse-breakdown embed for a found card. */ +export function renderCardEmbed( + entry: CoverageEntry, + scry: ScryfallCard | null, + build: Build, + meta: BuildMeta | null, +): Embed { + const counts = countItems(entry.parse_details); + const ok = entry.supported; + + const meter = supportMeter(counts.supported, counts.total); + const summary = ok + ? `${meter} ${counts.supported}/${counts.total} · fully supported` + : `${meter} ${counts.supported}/${counts.total} · ${entry.gap_count} gap${entry.gap_count === 1 ? "" : "s"}`; + + // Header (name · mana / type / oracle) sits up top; the coverage meter moves + // down to introduce the parse tree it summarizes. + const head = cardHeader(entry, scry); + + let description: string; + if (entry.parse_details.length === 0) { + description = `${head}\n\n${summary}\n\n*Vanilla — no parsed abilities*`; + } else { + const budget = DESCRIPTION_MAX - head.length - summary.length - 4; // "\n\n" + "\n\n" + description = `${head}\n\n${summary}\n\n${fitLines(treeLines(entry.parse_details), budget)}`; + } + + return { + author: { name: "ENGINE PARSE" }, + description, + color: ok ? COLOR_OK : COLOR_GAPS, + // Top-right thumbnail: compact beside the parse, click/tap to expand to the + // full card. Discord auto-reflows it on mobile. + thumbnail: scry?.image ? { url: scry.image } : undefined, + footer: { text: footerText(build, meta) }, + }; +} + +/** Builds the "card not found" embed. */ +export function renderNotFound(name: string, build: Build): Embed { + return { + author: { name: "ENGINE PARSE" }, + title: name, + description: `No card named **${name}** in the \`${build}\` build's parse data. Check the spelling, or pick a suggestion from autocomplete.`, + color: COLOR_GAPS, + }; +} diff --git a/scripts/card-bot/scryfall.ts b/scripts/card-bot/scryfall.ts new file mode 100644 index 0000000000..43615be696 --- /dev/null +++ b/scripts/card-bot/scryfall.ts @@ -0,0 +1,119 @@ +// Card image + header info, sourced exactly like the app (services/scryfall.ts): +// load scryfall-data.json from R2, cache it, and do pure in-memory lookups. No +// live Scryfall API — no per-card network call, no rate limits, no stalls. +// +// The stable manifest path is overwritten in place each deploy, so we keep the +// cache fresh with the same ETag stale-while-revalidate the coverage cache uses +// (hourly — Scryfall bulk data only changes on set releases). + +import { scryfallDataUrl } from "./config"; + +interface RawEntry { + oracle_id: string; + faces?: Array<{ normal: string; art_crop: string }>; + name: string; + mana_cost?: string; + type_line?: string; +} + +export interface ScryfallCard { + /** Canonical display name (with " // " for multi-face cards). */ + name: string; + /** Normal-size front-face image, or null. */ + image: string | null; + /** e.g. "Creature — Bear"; null if unavailable. */ + typeLine: string | null; + /** e.g. "{1}{G}" — includes " // " between split/adventure faces. */ + manaCost: string | null; + /** Scryfall page link (by oracle id). */ + scryfallUri: string | null; +} + +const REFRESH_MS = 60 * 60 * 1000; + +interface Cache { + map: Map; + etag: string | null; + checkedAt: number; +} + +let cache: Cache | null = null; +let loading: Promise | null = null; + +function compact(e: RawEntry): ScryfallCard { + return { + name: e.name, + image: e.faces?.[0]?.normal ?? null, + typeLine: e.type_line ?? null, + manaCost: e.mana_cost ?? null, + scryfallUri: e.oracle_id + ? `https://scryfall.com/search?q=oracleid%3A${e.oracle_id}` + : null, + }; +} + +type Fetched = { map: Map; etag: string | null }; + +/** Fetches + indexes the export. Returns "unchanged" on 304, null on error. */ +async function fetchCards(prevEtag: string | null): Promise { + try { + const res = await fetch(scryfallDataUrl(), { + headers: prevEtag ? { "If-None-Match": prevEtag } : {}, + signal: AbortSignal.timeout(30000), + }); + if (res.status === 304) return "unchanged"; + if (!res.ok) return null; + const etag = res.headers.get("etag"); + const raw = (await res.json()) as Record; + const map = new Map(); + for (const [key, entry] of Object.entries(raw)) map.set(key, compact(entry)); + console.log(`[scryfall] loaded ${map.size} entries`); + return { map, etag }; + } catch (err) { + console.error("[scryfall] fetch failed:", err); + return null; + } +} + +async function loadCache(): Promise { + const fetched = await fetchCards(null); + if (fetched && fetched !== "unchanged") { + cache = { map: fetched.map, etag: fetched.etag, checkedAt: Date.now() }; + } + return cache; +} + +/** Stale-while-revalidate: refresh the cache in the background when stale. */ +async function revalidate(): Promise { + if (!cache) return; + cache.checkedAt = Date.now(); + const fetched = await fetchCards(cache.etag); + if (fetched && fetched !== "unchanged") { + cache.map = fetched.map; + cache.etag = fetched.etag; + } +} + +async function ensure(): Promise { + if (cache) { + if (Date.now() - cache.checkedAt > REFRESH_MS) void revalidate().catch(() => {}); + return cache; + } + if (!loading) { + loading = loadCache().finally(() => { + loading = null; + }); + } + return loading; +} + +/** Looks up a card's image + header from the cached Scryfall export. */ +export async function lookupScryfall(name: string): Promise { + const c = await ensure(); + return c?.map.get(name.toLowerCase()) ?? null; +} + +/** Pre-loads the Scryfall export so the first query is instant. */ +export async function warmScryfall(): Promise { + await ensure(); +} diff --git a/scripts/card-bot/smoke.ts b/scripts/card-bot/smoke.ts new file mode 100644 index 0000000000..104edaa1a8 --- /dev/null +++ b/scripts/card-bot/smoke.ts @@ -0,0 +1,33 @@ +// Local render smoke test — no Discord credentials needed. +// bun scripts/card-bot/smoke.ts "Lightning Bolt" +// bun scripts/card-bot/smoke.ts "Snapcaster Mage" path/to/coverage-data.json +// +// Loads the local coverage export, renders the embed for a card, and prints the +// description with live ANSI so the terminal shows the same colors Discord will. + +import type { CoverageEntry } from "./coverageData"; +import { renderCardEmbed } from "./render"; +import { lookupScryfall } from "./scryfall"; + +const name = process.argv[2] ?? "Lightning Bolt"; +const file = process.argv[3] ?? "client/public/coverage-data.json"; + +const data = JSON.parse(await Bun.file(file).text()) as { cards: CoverageEntry[] }; +const entry = data.cards.find( + (c) => c.card_name?.toLowerCase() === name.toLowerCase(), +); +if (!entry) { + console.error(`"${name}" not found in ${file}`); + process.exit(1); +} + +const scry = await lookupScryfall(entry.card_name); +const embed = renderCardEmbed(entry, scry, "release", { + commit_short: "abc1234", + mtgjson_date: "2026-04-18", +}); + +console.log("=== embed (description elided) ==="); +console.log(JSON.stringify({ ...embed, description: "‹see below›" }, null, 2)); +console.log("\n=== description, ANSI live ===\n"); +console.log(embed.description); diff --git a/scripts/card-bot/upload-emoji.ts b/scripts/card-bot/upload-emoji.ts new file mode 100644 index 0000000000..1e91d56eb4 --- /dev/null +++ b/scripts/card-bot/upload-emoji.ts @@ -0,0 +1,91 @@ +// Bulk-creates application-owned emoji for MTG mana symbols, then writes the +// symbol → emoji-markup map the renderer uses. Run once after rasterizing PNGs +// (see the tmp/mana-emoji fetch step): +// bun scripts/card-bot/upload-emoji.ts [dir=tmp/mana-emoji] +// +// Needs CARD_BOT_TOKEN (the bot token) — Bun auto-loads it from .env. Idempotent: +// re-running reuses emoji already created on the app. +// +// Application emoji (up to 2000/app) work in any server the bot is in and don't +// consume per-server emoji slots. Their IDs are stable, so the generated +// manaEmoji.json is committed + baked into the image — the runtime container +// stays secretless (it never needs the token to render pips). + +import { discord } from "./config"; + +const API = "https://discord.com/api/v10"; +const DIR = process.argv[2] ?? "tmp/mana-emoji"; +const OUT_MAP = "scripts/card-bot/manaEmoji.json"; +const APP_ID = discord.appId(); +const TOKEN = discord.token(); + +interface AppEmoji { + id: string; + name: string; +} + +async function api(method: string, path: string, body?: unknown): Promise { + for (;;) { + const res = await fetch(`${API}${path}`, { + method, + headers: { + Authorization: `Bot ${TOKEN}`, + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (res.status === 429) { + const { retry_after } = (await res.json()) as { retry_after: number }; + await Bun.sleep(Math.ceil(retry_after * 1000) + 100); + continue; + } + if (!res.ok) { + throw new Error(`${method} ${path} → ${res.status}: ${await res.text()}`); + } + return (res.status === 204 ? null : await res.json()) as T; + } +} + +async function listExisting(): Promise> { + const body = await api<{ items: AppEmoji[] }>("GET", `/applications/${APP_ID}/emojis`); + const map = new Map(); + for (const e of body.items ?? []) map.set(e.name, e.id); + return map; +} + +async function dataUri(path: string): Promise { + const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); + return `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`; +} + +const symbolMap = (await Bun.file(`${DIR}/_symbol-map.json`).json()) as Record; +const existing = await listExisting(); + +const result: Record = {}; +let created = 0; +let reused = 0; + +for (const [symbol, name] of Object.entries(symbolMap)) { + let id = existing.get(name); + if (id) { + reused += 1; + } else { + const png = `${DIR}/${name}.png`; + if (!(await Bun.file(png).exists())) { + console.warn(`missing PNG for ${symbol} (${name}) — skipping`); + continue; + } + const emoji = await api("POST", `/applications/${APP_ID}/emojis`, { + name, + image: await dataUri(png), + }); + id = emoji.id; + created += 1; + await Bun.sleep(300); // gentle with the create rate limit + } + result[symbol] = `<:${name}:${id}>`; +} + +await Bun.write(OUT_MAP, `${JSON.stringify(result, null, 2)}\n`); +console.log(`Application emoji: ${created} created, ${reused} reused.`); +console.log(`Wrote ${Object.keys(result).length} mappings → ${OUT_MAP}`); From 3140c0fc3593cbbe489c4b8d9c9b6f26f54c4eca Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Mon, 25 May 2026 14:23:02 -0700 Subject: [PATCH 18/22] feat(deck-builder): local engine-backed card search, commander hover preview, bracket relocation Replace the deck builder's Scryfall API search with a local engine search. card-data.json (already loaded into the WASM CardDatabase) carries legalities, printings, types, colors, and oracle text, so search now runs entirely in the engine with zero network calls. - engine: CardDatabase::search filters by text (name + oracle text), colors (CR 105.2 superset), type, mana value (CR 202.3), legality, and sets; dedupes multi-face cards by oracle id. Exported to WASM as search_cards_js. - frontend: searchCards() calls the engine and hydrates artwork/type line from the local scryfall-data image map by oracle id. Removed searchScryfall / buildScryfallQuery (the api.scryfall.com/cards/search path). - CommanderPanel: wire mouseHoverPreview onto the selected commander and the eligible-commander buttons so hovering shows the card preview. - bracket: move BracketPicker out of the toolbar into the info rail beside the BracketAuditPanel, so selecting Commander no longer reflows the format wall. Adds engine unit tests covering each search filter axis, oracle-id dedup, and limit/total semantics. --- .../components/deck-builder/CardSearch.tsx | 35 +- .../deck-builder/CommanderPanel.tsx | 5 + .../components/deck-builder/DeckBuilder.tsx | 4 +- .../deck-builder/DeckBuilderToolbar.tsx | 14 - .../components/deck-builder/StatsPanel.tsx | 26 +- .../src/services/__tests__/scryfall.test.ts | 30 - client/src/services/engineRuntime.ts | 73 +++ client/src/services/scryfall.ts | 103 ++-- client/src/wasm/engine_wasm.d.ts | 10 + crates/engine-wasm/src/lib.rs | 22 +- crates/engine/src/database/mod.rs | 2 + crates/engine/src/database/search.rs | 537 ++++++++++++++++++ 12 files changed, 725 insertions(+), 136 deletions(-) create mode 100644 crates/engine/src/database/search.rs diff --git a/client/src/components/deck-builder/CardSearch.tsx b/client/src/components/deck-builder/CardSearch.tsx index 92d2d7c154..e36ba92b26 100644 --- a/client/src/components/deck-builder/CardSearch.tsx +++ b/client/src/components/deck-builder/CardSearch.tsx @@ -1,11 +1,7 @@ import { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { - searchScryfall, - buildScryfallQuery, - scryfallLegalityKey, - type ScryfallCard, -} from "../../services/scryfall"; +import { scryfallLegalityKey, type ScryfallCard } from "../../services/scryfall"; +import { searchCards } from "../../services/engineRuntime"; import type { GameFormat } from "../../adapter/types"; import { FORMAT_REGISTRY } from "../../data/formatRegistry"; import { useSetList } from "../../hooks/useSetList"; @@ -122,28 +118,23 @@ export function CardSearch({ return; } - const query = buildScryfallQuery({ - text: searchText || undefined, - colors: colors.length > 0 ? colors : undefined, - type: type || undefined, - cmcMax: cmc, - sets, - format: browseFormat === "all" ? undefined : scryfallLegalityKey(browseFormat), - }); - - if (!query) { - onResults([], 0); - setResultCount(null); - return; - } - + // AbortController is the staleness guard: a newer search aborts this one + // so its results never overwrite the latest. The search itself runs + // locally through the engine (no network, no signal to pass). const controller = new AbortController(); abortRef.current = controller; setLoading(true); setError(null); try { - const { cards, total } = await searchScryfall(query, controller.signal); + const { cards, total } = await searchCards({ + text: searchText || undefined, + colors: colors.length > 0 ? colors : undefined, + type: type || undefined, + cmcMax: cmc, + sets, + legalFormat: browseFormat === "all" ? undefined : scryfallLegalityKey(browseFormat), + }); if (!controller.signal.aborted) { onResults(cards, total); setResultCount(total); diff --git a/client/src/components/deck-builder/CommanderPanel.tsx b/client/src/components/deck-builder/CommanderPanel.tsx index 1cb3e0e3ee..fb8cba1bc4 100644 --- a/client/src/components/deck-builder/CommanderPanel.tsx +++ b/client/src/components/deck-builder/CommanderPanel.tsx @@ -7,6 +7,7 @@ import { getColorIdentityViolations, getSingletonViolations, } from "./commanderUtils"; +import { mouseHoverPreview } from "./hoverPreview"; const WUBRG_COLORS = ["W", "U", "B", "R", "G"] as const; @@ -26,6 +27,7 @@ interface CommanderPanelProps { isCommanderEligible: (name: string) => boolean; onSetCommander: (cardName: string) => void; onRemoveCommander: (cardName: string) => void; + onCardHover?: (cardName: string | null) => void; } @@ -37,6 +39,7 @@ export function CommanderPanel({ isCommanderEligible, onSetCommander, onRemoveCommander, + onCardHover, }: CommanderPanelProps) { const { t } = useTranslation("deck-builder"); const identity = getCombinedColorIdentity(commanders, cardDataCache); @@ -70,6 +73,7 @@ export function CommanderPanel({ return (
@@ -113,6 +117,7 @@ export function CommanderPanel({
diff --git a/client/src/components/deck-builder/StatsPanel.tsx b/client/src/components/deck-builder/StatsPanel.tsx index ad419f9fac..98d1cecc4b 100644 --- a/client/src/components/deck-builder/StatsPanel.tsx +++ b/client/src/components/deck-builder/StatsPanel.tsx @@ -6,6 +6,7 @@ import { FORMAT_REGISTRY } from "../../data/formatRegistry"; import type { BracketEstimate, CommanderBracket } from "../../types/bracket"; import { ManaCurve } from "./ManaCurve"; import { BracketAuditPanel } from "./BracketAuditPanel"; +import { BracketPicker } from "./BracketPicker"; const LEGALITY_STYLES: Record = { legal: "bg-emerald-600/70 text-emerald-100", @@ -41,6 +42,7 @@ interface StatsPanelProps { isCommander: boolean; estimate: BracketEstimate | null; manualBracket: CommanderBracket | null; + onBracketChange: (bracket: CommanderBracket | null) => void; auditEmptyReason?: "not-commander" | "no-commander" | "unsupported"; onCardClick: (cardName: string) => void; } @@ -52,6 +54,7 @@ export function StatsPanel({ isCommander, estimate, manualBracket, + onBracketChange, auditEmptyReason, onCardClick, }: StatsPanelProps) { @@ -65,12 +68,23 @@ export function StatsPanel({ return (
{isCommander && ( - +
+ {/* The bracket picker lives beside the audit it's compared against, so + setting a bracket and seeing the deck's estimated tier (and any + mismatch) read as one unit. Both are Commander-only. */} +
+ + {t("toolbar.bracket")} + + +
+ +
)}
diff --git a/client/src/services/__tests__/scryfall.test.ts b/client/src/services/__tests__/scryfall.test.ts index e9cadefb3b..fd05cbf1d0 100644 --- a/client/src/services/__tests__/scryfall.test.ts +++ b/client/src/services/__tests__/scryfall.test.ts @@ -78,36 +78,6 @@ describe("normalizeCardName", () => { }); }); -describe("buildScryfallQuery", () => { - it("adds a single set filter", async () => { - const { buildScryfallQuery } = await loadScryfallModule(); - - expect(buildScryfallQuery({ - text: "lightning", - sets: ["DMU"], - format: "standard", - })).toBe("lightning set:dmu f:standard"); - }); - - it("groups multiple set filters with OR", async () => { - const { buildScryfallQuery } = await loadScryfallModule(); - - expect(buildScryfallQuery({ - type: "Artifact", - sets: ["DMU", "BRO"], - format: "standard", - })).toBe("t:Artifact (set:dmu OR set:bro) f:standard"); - }); - - it("deduplicates and trims set filters", async () => { - const { buildScryfallQuery } = await loadScryfallModule(); - - expect(buildScryfallQuery({ - sets: [" dmu ", "DMU", "bro"], - })).toBe("(set:dmu OR set:bro)"); - }); -}); - describe("scryfallLegalityKey", () => { it("uses Scryfall legality keys for constructed formats", async () => { const { scryfallLegalityKey } = await loadScryfallModule(); diff --git a/client/src/services/engineRuntime.ts b/client/src/services/engineRuntime.ts index ae26f27e91..c161411411 100644 --- a/client/src/services/engineRuntime.ts +++ b/client/src/services/engineRuntime.ts @@ -3,6 +3,11 @@ import type { TokenCharacteristics, TokenImageRef, } from "../adapter/types"; +import { + buildLocalSearchCard, + loadScryfallData, + type ScryfallCard, +} from "./scryfall"; type EngineModule = typeof import("@wasm/engine"); @@ -90,6 +95,74 @@ export async function getCardParseDetails(cardName: string) { return engine.get_card_parse_details(cardName); } +/** + * A deck-builder card search. Mirrors the engine's `CardSearchQuery` + * (`crates/engine/src/database/search.rs`). All fields optional; an empty query + * matches nothing — callers gate on "has criteria" first. + */ +export interface CardSearchQuery { + text?: string; + /** WUBRG color letters the card's colors must include (superset match). */ + colors?: string[]; + /** A type word (core type, supertype, or subtype). */ + type?: string; + cmcMax?: number; + /** Set codes; card must have a printing in at least one. */ + sets?: string[]; + /** A legality-format key (e.g. `"modern"`); card must be legal in it. */ + legalFormat?: string; + limit?: number; +} + +/** Engine result shape — rules data only (see `CardSearchResult` in the engine). */ +interface EngineCardSearchResult { + name: string; + oracle_id: string | null; + mana_value: number; + color_identity: string[]; + legalities: Record; +} + +interface EngineCardSearchResults { + results: EngineCardSearchResult[]; + total: number; +} + +/** + * Search the local card database through the engine. The engine is the single + * authority for the rules data search filters on (legality, sets, types, mana + * value, colors); the frontend hydrates artwork and type lines from the local + * Scryfall image map. No network search ever leaves the device. + */ +export async function searchCards( + query: CardSearchQuery, +): Promise<{ cards: ScryfallCard[]; total: number }> { + await ensureCardDatabase(); + // Hydration of artwork/type line needs the image map resolved. + await loadScryfallData(); + const engine = await loadEngineModule(); + const { results, total } = engine.search_cards_js({ + text: query.text ?? "", + colors: query.colors ?? [], + type_line: query.type ?? "", + cmc_max: query.cmcMax ?? null, + sets: query.sets ?? [], + legal_format: query.legalFormat ?? null, + limit: query.limit ?? null, + }) as EngineCardSearchResults; + + const cards = results.map((result) => + buildLocalSearchCard({ + oracleId: result.oracle_id ?? undefined, + name: result.name, + cmc: result.mana_value, + colorIdentity: result.color_identity, + legalities: result.legalities, + }), + ); + return { cards, total }; +} + export async function getCardRulings(cardName: string): Promise { await ensureCardDatabase(); const engine = await loadEngineModule(); diff --git a/client/src/services/scryfall.ts b/client/src/services/scryfall.ts index 1d9a96cc78..2b1f5bc12a 100644 --- a/client/src/services/scryfall.ts +++ b/client/src/services/scryfall.ts @@ -54,7 +54,7 @@ let printingsDataPromise: Promise | null = null; let tokenImagesDataPromise: Promise | null = null; let scryfallQueue: Promise = Promise.resolve(); -function loadScryfallData(): Promise { +export function loadScryfallData(): Promise { if (!scryfallDataPromise) { scryfallDataPromise = fetch(__SCRYFALL_DATA_URL__) .then((r) => r.json() as Promise) @@ -358,6 +358,47 @@ export async function fetchCardData(cardName: string): Promise { }; } +/** + * Engine-authoritative fields for a card-search result. The engine owns these + * (mana value, color identity, legality) — see `crates/engine/src/database/search.rs`. + */ +export interface LocalSearchCardOverrides { + oracleId?: string; + name: string; + cmc: number; + colorIdentity: string[]; + legalities: Record; +} + +/** + * Build a display `ScryfallCard` for an engine search result. Rules data comes + * from the engine (the `overrides`); presentation data — artwork, printed type + * line, colors, mana cost, keywords — is hydrated from the already-loaded local + * image map, keyed by `oracleId` (falling back to name). Requires + * `loadScryfallData()` to have resolved; returns a usable card even when the + * image entry is missing (the grid renders a text-tile fallback). + */ +export function buildLocalSearchCard(overrides: LocalSearchCardOverrides): ScryfallCard { + const entry = + (overrides.oracleId + ? scryfallDataResolved?.[overrides.oracleId.toLowerCase()] + : undefined) ?? scryfallDataResolved?.[overrides.name.toLowerCase()]; + const face = entry?.faces[0]; + return { + name: entry?.name ?? overrides.name, + mana_cost: entry?.mana_cost ?? "", + cmc: overrides.cmc, + type_line: entry?.type_line ?? "", + colors: entry?.colors ?? [], + color_identity: overrides.colorIdentity, + keywords: entry?.keywords ?? [], + legalities: overrides.legalities, + image_uris: face + ? { art_crop: face.art_crop, normal: face.normal, small: face.normal, large: face.normal } + : undefined, + }; +} + function getImageUrl( card: ScryfallCard, size: ImageSize, @@ -624,66 +665,6 @@ function buildTokenColorClause(colors: string[] | undefined | null): string { return colorStr ? ` c=${colorStr}` : " c=c"; } -/** - * Search Scryfall for cards matching query. Uses rate limiting and handles 429s. - */ -export async function searchScryfall( - query: string, - signal?: AbortSignal, -): Promise<{ cards: ScryfallCard[]; total: number }> { - const url = `https://api.scryfall.com/cards/search?q=${encodeURIComponent(query)}`; - const response = await rateLimitedFetch(url); - - if (signal?.aborted) { - return { cards: [], total: 0 }; - } - - if (response.status === 404) { - return { cards: [], total: 0 }; - } - - if (!response.ok) { - throw new Error(`Scryfall search error: ${response.status}`); - } - - const data: ScryfallSearchResponse = await response.json(); - return { cards: data.data, total: data.total_cards }; -} - -/** Build Scryfall query string from filter options. */ -export function buildScryfallQuery(options: { - text?: string; - colors?: string[]; - type?: string; - cmcMax?: number; - cmcMin?: number; - sets?: string[]; - format?: string; -}): string { - const parts: string[] = []; - - if (options.text) parts.push(options.text); - if (options.colors?.length) parts.push(`c:${options.colors.join("")}`); - if (options.type) parts.push(`t:${options.type}`); - if (options.cmcMin !== undefined) parts.push(`cmc>=${options.cmcMin}`); - if (options.cmcMax !== undefined) parts.push(`cmc<=${options.cmcMax}`); - if (options.sets?.length) { - const uniqueSetCodes = [...new Set( - options.sets - .map((setCode) => setCode.trim().toLowerCase()) - .filter(Boolean), - )]; - if (uniqueSetCodes.length === 1) { - parts.push(`set:${uniqueSetCodes[0]}`); - } else if (uniqueSetCodes.length > 1) { - parts.push(`(${uniqueSetCodes.map((setCode) => `set:${setCode}`).join(" OR ")})`); - } - } - if (options.format) parts.push(`f:${options.format}`); - - return parts.join(" "); -} - /** Get the best image URI for a card (handles double-faced cards). */ export function getCardImageSmall(card: ScryfallCard): string { return card.image_uris?.small diff --git a/client/src/wasm/engine_wasm.d.ts b/client/src/wasm/engine_wasm.d.ts index 3ff876015a..89c06b57e5 100644 --- a/client/src/wasm/engine_wasm.d.ts +++ b/client/src/wasm/engine_wasm.d.ts @@ -242,6 +242,15 @@ export function restore_game_state(json_str: string): void; */ export function resume_multiplayer_host_state(json_str: string): void; +/** + * Search the loaded card database. The engine is the single authority for the + * rules data search filters on — format legality, set membership, card types, + * mana value, and colors — so deck-builder search runs here, never as a + * third-party API call. Returns `{ results, total }` (see `CardSearchResults`), + * or an error if the database is not loaded or the query is malformed. + */ +export function search_cards_js(query: any): any; + /** * Select an action from merged scores using softmax. * Called after collecting scored candidates from parallel workers and merging. @@ -323,6 +332,7 @@ export interface InitOutput { readonly resolve_all: (a: number, b: number, c: number, d: number) => [number, number, number]; readonly restore_game_state: (a: number, b: number) => [number, number]; readonly resume_multiplayer_host_state: (a: number, b: number) => [number, number]; + readonly search_cards_js: (a: any) => [number, number, number]; readonly select_action_from_scores: (a: number, b: number, c: number, d: number, e: bigint) => [number, number, number]; readonly set_multiplayer_mode: (a: number) => void; readonly sideboardPolicyForFormat: (a: any) => [number, number, number]; diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index d09b9d3644..1f230b183b 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -6,7 +6,7 @@ use serde::Serialize; use wasm_bindgen::prelude::*; use engine::ai_support::{auto_pass_recommended, legal_actions_for_viewer, legal_actions_full}; -use engine::database::CardDatabase; +use engine::database::{CardDatabase, CardSearchQuery}; use engine::game::engine::apply; use engine::game::{ estimate_bracket, evaluate_deck_compatibility, filter_state_for_viewer, finalize_public_state, @@ -236,6 +236,26 @@ pub fn get_card_face_data(name: &str) -> JsValue { }) } +/// Search the loaded card database. The engine is the single authority for the +/// rules data search filters on — format legality, set membership, card types, +/// mana value, and colors — so deck-builder search runs here, never as a +/// third-party API call. Returns `{ results, total }` (see `CardSearchResults`), +/// or an error if the database is not loaded or the query is malformed. +#[wasm_bindgen] +pub fn search_cards_js(query: JsValue) -> Result { + let query: CardSearchQuery = serde_wasm_bindgen::from_value(query) + .map_err(|e| JsValue::from_str(&format!("Invalid search query: {e}")))?; + CARD_DB.with(|cell| { + let db = cell.borrow(); + let Some(db) = db.as_ref() else { + return Err(JsValue::from_str( + "Card database not loaded. Call load_card_database first.", + )); + }; + Ok(to_js(&db.search(&query))) + }) +} + /// Returns the official WotC rulings for a card as a JS array of `{date, text}` /// objects. Returns an empty array if the card is not found, the database is /// not loaded, or the card has no rulings (back faces of multi-face cards diff --git a/crates/engine/src/database/mod.rs b/crates/engine/src/database/mod.rs index 3126a50cde..99a1ff2a0f 100644 --- a/crates/engine/src/database/mod.rs +++ b/crates/engine/src/database/mod.rs @@ -5,10 +5,12 @@ pub mod forge; pub mod legality; pub mod mtgjson; pub mod oracle_loader; +pub mod search; pub mod synthesis; pub use bracket_lists::{BracketLists, BracketSignals}; pub use card_db::CardDatabase; +pub use search::{CardSearchQuery, CardSearchResult, CardSearchResults}; /// Single authority for "is this card runnable by the engine right now?" /// diff --git a/crates/engine/src/database/search.rs b/crates/engine/src/database/search.rs new file mode 100644 index 0000000000..c0073e83de --- /dev/null +++ b/crates/engine/src/database/search.rs @@ -0,0 +1,537 @@ +//! Local card search over the loaded `CardDatabase`. +//! +//! This is the single authority for deck-builder card search. The engine owns +//! the rules data the search filters on — format legality (banned-list data), +//! set membership, card types (CR 205), mana value (CR 202.3), and a card's +//! colors (CR 105.2, derived from its mana symbols and color indicator) — so +//! search lives here rather than as a third-party HTTP call from the display +//! layer. +//! +//! Results carry only rules data (name, oracle id, mana value, color identity, +//! legalities). Presentation data (artwork, printed type line) is hydrated by +//! the frontend from its local Scryfall image map, keyed by the returned +//! `oracle_id`. + +use std::collections::{BTreeMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +use super::card_db::CardDatabase; +use super::legality::LegalityFormat; +use crate::types::card::CardFace; +use crate::types::card_type::CardType; +use crate::types::mana::{ManaColor, ManaCost}; + +/// Default page size, mirroring Scryfall's search page size so the grid renders +/// a comparable result count. `total` still reports the full match count. +const DEFAULT_LIMIT: usize = 175; + +/// A card-search request from the deck builder. All fields are optional; an +/// all-empty query matches every card (each filter is skipped when empty), so +/// callers gate on "has criteria" before searching. +#[derive(Debug, Default, Deserialize)] +pub struct CardSearchQuery { + /// Free text, matched word-by-word (AND) against name + oracle text + type. + #[serde(default)] + pub text: String, + /// WUBRG color letters the card's colors must include (superset match, + /// mirroring Scryfall's `c:` operator — CR 105.2). + #[serde(default)] + pub colors: Vec, + /// A type word (core type, supertype, or subtype), matched case-insensitively. + #[serde(default)] + pub type_line: String, + /// Inclusive upper bound on mana value (CR 202.3). + #[serde(default)] + pub cmc_max: Option, + /// Set codes; the card must have a printing in at least one. Format + /// legality is a separate filter (`legal_format`). + #[serde(default)] + pub sets: Vec, + /// A legality-format key (e.g. `"modern"`); the card must be `legal` in it. + #[serde(default)] + pub legal_format: Option, + /// Max results returned (defaults to [`DEFAULT_LIMIT`]); `total` is unbounded. + #[serde(default)] + pub limit: Option, +} + +/// One matching card. Rules data only — the frontend hydrates artwork and the +/// printed type line from its local image map using `oracle_id`. +#[derive(Debug, Serialize)] +pub struct CardSearchResult { + pub name: String, + pub oracle_id: Option, + pub mana_value: u32, + pub color_identity: Vec<&'static str>, + pub legalities: BTreeMap, +} + +/// A page of results plus the full match count (which may exceed `results.len()` +/// when the page limit truncates). +#[derive(Debug, Serialize)] +pub struct CardSearchResults { + pub results: Vec, + pub total: usize, +} + +impl CardDatabase { + /// Filter the loaded cards by the query, deduplicating multi-face cards by + /// oracle id. Name/text matches sort ahead of incidental oracle-text hits, + /// then alphabetically. + pub fn search(&self, query: &CardSearchQuery) -> CardSearchResults { + let needle = query.text.trim().to_lowercase(); + let words: Vec<&str> = needle.split_whitespace().collect(); + let requested_colors: Vec = query + .colors + .iter() + .filter_map(|c| parse_color_letter(c)) + .collect(); + let type_needle = query.type_line.trim().to_lowercase(); + let legal_format = query + .legal_format + .as_deref() + .and_then(LegalityFormat::from_key); + let requested_sets: Vec = query.sets.iter().map(|s| s.to_uppercase()).collect(); + + let mut seen_oracle: HashSet<&str> = HashSet::new(); + // (relevance rank, lowercased name for tiebreak, result) + let mut matched: Vec<(u8, String, CardSearchResult)> = Vec::new(); + + for (key, face) in self.face_index.iter() { + let name_lower = face.name.to_lowercase(); + + if !words.is_empty() && !text_matches(&name_lower, face, &words) { + continue; + } + if !requested_colors.is_empty() { + let colors = face_colors(face); + if !requested_colors.iter().all(|c| colors.contains(c)) { + continue; + } + } + if !type_needle.is_empty() && !type_matches(&face.card_type, &type_needle) { + continue; + } + if let Some(max) = query.cmc_max { + if face.mana_cost.mana_value() > max { + continue; + } + } + if let Some(format) = legal_format { + let legal = self + .legalities + .get(key) + .and_then(|m| m.get(&format)) + .is_some_and(|status| status.is_legal()); + if !legal { + continue; + } + } + if !requested_sets.is_empty() { + let in_set = self + .printings_index + .get(key) + .is_some_and(|sets| sets.iter().any(|s| requested_sets.contains(s))); + if !in_set { + continue; + } + } + + // Deduplicate multi-face cards: keep the first matching face per + // oracle id. The frontend re-derives the combined display name from + // the image map, so which face won here doesn't affect display. + if let Some(oracle_id) = face.scryfall_oracle_id.as_deref() { + if !seen_oracle.insert(oracle_id) { + continue; + } + } + + let rank = if needle.is_empty() || name_lower.contains(&needle) { + 0 + } else { + 1 + }; + matched.push((rank, name_lower, self.build_result(key, face))); + } + + let total = matched.len(); + matched.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + + let limit = query.limit.unwrap_or(DEFAULT_LIMIT); + let results = matched + .into_iter() + .take(limit) + .map(|(_, _, result)| result) + .collect(); + + CardSearchResults { results, total } + } + + fn build_result(&self, key: &str, face: &CardFace) -> CardSearchResult { + let legalities = self + .legalities + .get(key) + .map(|m| { + m.iter() + .map(|(format, status)| { + ( + format.as_key().to_string(), + status.as_export_str().to_string(), + ) + }) + .collect() + }) + .unwrap_or_default(); + + CardSearchResult { + name: face.name.clone(), + oracle_id: face.scryfall_oracle_id.clone(), + mana_value: face.mana_cost.mana_value(), + color_identity: face + .color_identity + .iter() + .copied() + .map(color_letter) + .collect(), + legalities, + } + } +} + +/// Word-AND match across the card's name, oracle text, and type line — the +/// fields Scryfall's default full-text search covers. +fn text_matches(name_lower: &str, face: &CardFace, words: &[&str]) -> bool { + let mut haystack = name_lower.to_string(); + if let Some(text) = &face.oracle_text { + haystack.push(' '); + haystack.push_str(&text.to_lowercase()); + } + words.iter().all(|w| haystack.contains(w)) +} + +/// A card's colors per CR 105.2: the colors of its mana-cost symbols, plus any +/// color indicator (`color_override`). Hybrid symbols count for each color. +fn face_colors(face: &CardFace) -> Vec { + let mut colors: Vec = Vec::new(); + if let ManaCost::Cost { shards, .. } = &face.mana_cost { + for &color in &ManaColor::ALL { + if shards.iter().any(|s| s.contributes_to(color)) { + colors.push(color); + } + } + } + if let Some(indicator) = &face.color_override { + for &color in indicator { + if !colors.contains(&color) { + colors.push(color); + } + } + } + colors +} + +/// Case-insensitive type match against core types, supertypes, and subtypes. +fn type_matches(card_type: &CardType, needle: &str) -> bool { + card_type + .core_types + .iter() + .any(|t| t.to_string().to_lowercase().contains(needle)) + || card_type + .supertypes + .iter() + .any(|t| t.to_string().to_lowercase().contains(needle)) + || card_type + .subtypes + .iter() + .any(|s| s.to_lowercase().contains(needle)) +} + +fn parse_color_letter(letter: &str) -> Option { + match letter.trim().to_uppercase().as_str() { + "W" => Some(ManaColor::White), + "U" => Some(ManaColor::Blue), + "B" => Some(ManaColor::Black), + "R" => Some(ManaColor::Red), + "G" => Some(ManaColor::Green), + _ => None, + } +} + +fn color_letter(color: ManaColor) -> &'static str { + match color { + ManaColor::White => "W", + ManaColor::Blue => "U", + ManaColor::Black => "B", + ManaColor::Red => "R", + ManaColor::Green => "G", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + + /// Build one export entry. `shards`/`generic` form the mana cost (shard and + /// `color_identity` names use the engine's full color spelling, e.g. + /// `"Red"`, matching `card-data.json`), and `legalities`/`printings` drive + /// the legality and set filters. + #[allow(clippy::too_many_arguments)] + fn card( + name: &str, + oracle_id: &str, + shards: &[&str], + generic: u32, + core_type: &str, + color_identity: &[&str], + oracle_text: &str, + legalities: Value, + printings: &[&str], + ) -> Value { + json!({ + "name": name, + "mana_cost": { "type": "Cost", "shards": shards, "generic": generic }, + "card_type": { "supertypes": [], "core_types": [core_type], "subtypes": [] }, + "power": null, "toughness": null, "loyalty": null, "defense": null, + "oracle_text": oracle_text, + "non_ability_text": null, "flavor_name": null, + "keywords": [], "abilities": [], "triggers": [], + "static_abilities": [], "replacements": [], + "color_override": null, + "color_identity": color_identity, + "scryfall_oracle_id": oracle_id, + "legalities": legalities, + "printings": printings, + }) + } + + fn db_from(cards: &[(&str, Value)]) -> CardDatabase { + let map: serde_json::Map = cards + .iter() + .map(|(key, value)| (key.to_string(), value.clone())) + .collect(); + CardDatabase::from_json_str(&Value::Object(map).to_string()).unwrap() + } + + fn result_names(results: &CardSearchResults) -> Vec { + results.results.iter().map(|r| r.name.clone()).collect() + } + + fn sample_db() -> CardDatabase { + db_from(&[ + ( + "lightning bolt", + card( + "Lightning Bolt", + "o-bolt", + &["Red"], + 0, + "Instant", + &["Red"], + "Lightning Bolt deals 3 damage to any target.", + json!({ "modern": "legal" }), + &["LEA", "M10"], + ), + ), + ( + "grizzly bears", + card( + "Grizzly Bears", + "o-bears", + &["Green"], + 1, + "Creature", + &["Green"], + "", + json!({ "modern": "legal" }), + &["LEA"], + ), + ), + ( + "shock", + card( + "Shock", + "o-shock", + &["Red"], + 0, + "Instant", + &["Red"], + "Shock deals 2 damage to any target.", + json!({ "modern": "banned" }), + &["M10"], + ), + ), + ]) + } + + #[test] + fn text_matches_name_only() { + let res = sample_db().search(&CardSearchQuery { + text: "bolt".into(), + ..Default::default() + }); + assert_eq!(result_names(&res), vec!["Lightning Bolt"]); + } + + #[test] + fn text_matches_oracle_text_and_ranks_name_hits_first() { + // "damage" hits the oracle text of both Bolt and Shock (neither name), + // so both return, ordered alphabetically. + let res = sample_db().search(&CardSearchQuery { + text: "damage".into(), + ..Default::default() + }); + assert_eq!(result_names(&res), vec!["Lightning Bolt", "Shock"]); + } + + #[test] + fn color_filter_is_superset_match() { + let db = db_from(&[ + ( + "azorius", + card( + "Azorius Card", + "o-az", + &["White", "Blue"], + 0, + "Creature", + &["White", "Blue"], + "", + json!({}), + &[], + ), + ), + ( + "white", + card( + "White Card", + "o-w", + &["White"], + 0, + "Creature", + &["White"], + "", + json!({}), + &[], + ), + ), + ]); + // {W} matches both the mono-white card and the WU card (superset). + let res = db.search(&CardSearchQuery { + colors: vec!["W".into()], + ..Default::default() + }); + assert_eq!(result_names(&res), vec!["Azorius Card", "White Card"]); + // {W}{U} matches only the WU card; mono-white lacks blue. + let res = db.search(&CardSearchQuery { + colors: vec!["W".into(), "U".into()], + ..Default::default() + }); + assert_eq!(result_names(&res), vec!["Azorius Card"]); + } + + #[test] + fn type_filter_matches_core_type() { + let res = sample_db().search(&CardSearchQuery { + type_line: "creature".into(), + ..Default::default() + }); + assert_eq!(result_names(&res), vec!["Grizzly Bears"]); + } + + #[test] + fn cmc_max_is_inclusive_upper_bound() { + // Bolt/Shock are mana value 1; Grizzly Bears is {1}{G} = 2. + let res = sample_db().search(&CardSearchQuery { + cmc_max: Some(1), + ..Default::default() + }); + assert_eq!(result_names(&res), vec!["Lightning Bolt", "Shock"]); + } + + #[test] + fn legal_format_excludes_banned_and_unknown() { + // Shock is banned in modern; Bolt and Bears are legal. + let res = sample_db().search(&CardSearchQuery { + legal_format: Some("modern".into()), + ..Default::default() + }); + assert_eq!(result_names(&res), vec!["Grizzly Bears", "Lightning Bolt"]); + } + + #[test] + fn sets_filter_requires_a_printing_in_set() { + let res = sample_db().search(&CardSearchQuery { + sets: vec!["m10".into()], // case-insensitive + ..Default::default() + }); + assert_eq!(result_names(&res), vec!["Lightning Bolt", "Shock"]); + } + + #[test] + fn multi_face_cards_dedupe_by_oracle_id() { + let db = db_from(&[ + ( + "front face", + card( + "Front Face", + "o-dfc", + &["Blue"], + 1, + "Creature", + &["Blue"], + "", + json!({}), + &[], + ), + ), + ( + "back face", + card( + "Back Face", + "o-dfc", + &[], + 0, + "Land", + &[], + "", + json!({}), + &[], + ), + ), + ]); + let res = db.search(&CardSearchQuery { + text: "face".into(), + ..Default::default() + }); + assert_eq!(res.results.len(), 1, "both faces share an oracle id"); + assert_eq!(res.total, 1); + } + + #[test] + fn limit_truncates_results_but_total_is_full_count() { + let res = sample_db().search(&CardSearchQuery { + limit: Some(1), + ..Default::default() + }); + assert_eq!(res.results.len(), 1); + assert_eq!(res.total, 3, "total reflects all matches, not the page"); + } + + #[test] + fn result_carries_engine_authoritative_fields() { + let res = sample_db().search(&CardSearchQuery { + text: "lightning bolt".into(), + ..Default::default() + }); + let bolt = &res.results[0]; + assert_eq!(bolt.oracle_id.as_deref(), Some("o-bolt")); + assert_eq!(bolt.mana_value, 1); + assert_eq!(bolt.color_identity, vec!["R"]); + assert_eq!( + bolt.legalities.get("modern").map(String::as_str), + Some("legal") + ); + } +} From e0e09b42f0b330fa603ad287d39ee69b8cd538b0 Mon Sep 17 00:00:00 2001 From: Luiz Servando Date: Mon, 25 May 2026 18:39:34 -0300 Subject: [PATCH 19/22] Add Inversion Behemoth parser support Adds prepositional SwitchPT parsing for "switch the power and toughness of ..." forms, including any-number target support, and cleans up the parser branch to use nom/context-aware target parsing. --- .../src/parser/oracle_effect/imperative.rs | 50 +++++++++++ crates/engine/src/parser/oracle_effect/mod.rs | 82 +++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 0e1ba3b91f..3ce5313dba 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -2820,6 +2820,56 @@ pub(super) fn parse_utility_imperative_ast( return Some(UtilityImperativeAst::Transform { target }); } } + // CR 613.4d: switch power and toughness — two surface forms (sibling branches): + // - prepositional: "switch the power and toughness of " (Inversion + // Behemoth class — supports the "(each of) any number of target X" + // distribution, with multi_target recovered by + // `extract_switch_pt_multi_target` in the post-parse fixup. Authorizing + // rule for variable-count targeting: CR 115.1d.) + // - possessive: "switch 's power and toughness" (single-target + // class — Inversion of Fortune, Twiddle's siblings). + // Try prepositional first so the more specific "the power and toughness of" + // shape is consumed before the bare "switch " form runs. + if let Some((_, rest)) = nom_on_lower(text, lower, |input| { + value((), tag("switch the power and toughness of ")).parse(input) + }) { + // Strip the optional "each of " and "any number of " distribution + // prefixes so `parse_target` sees a bare target phrase. The quantifier + // itself is recovered as a `MultiTargetSpec` in mod.rs via + // `extract_switch_pt_multi_target` (parallel to the DealDamage / Double + // counter fixups). Walking the lowercased view in lock-step with the + // original text preserves casing for `parse_target`. + let rest_lower = rest.to_ascii_lowercase(); + let mut consumed = 0usize; + if let Ok((after, _)) = tag::<_, _, OracleError<'_>>("each of ").parse(rest_lower.as_str()) + { + consumed = rest_lower.len() - after.len(); + } + let after_each_lower = &rest_lower[consumed..]; + if let Ok((after, _)) = + tag::<_, _, OracleError<'_>>("any number of ").parse(after_each_lower) + { + consumed += after_each_lower.len() - after.len(); + } + let target_text = &rest[consumed..]; + let (target, rem) = parse_target_with_ctx(target_text, ctx); + let rem_lower = rem.trim_start().to_ascii_lowercase(); + // The trailing duration ("until end of turn") is stripped upstream by + // `strip_trailing_duration`; in that case `rem` is empty. Accept either + // form so the branch also matches when this parser is invoked directly + // on text that retains the duration (e.g. unit tests). + let rem_after_duration = tag::<_, _, OracleError<'_>>("until end of turn") + .parse(rem_lower.as_str()) + .map(|(rest, _)| rest) + .unwrap_or(rem_lower.as_str()); + let mut terminal = alt(( + value((), eof), + value((), all_consuming(tag::<_, _, OracleError<'_>>("."))), + )); + if terminal.parse(rem_after_duration).is_ok() { + return Some(UtilityImperativeAst::SwitchPT { target }); + } + } // CR 613.4d: "switch [target]'s power and toughness" if let Some((_, rest)) = nom_on_lower(text, lower, |input| value((), tag("switch ")).parse(input)) diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 969f5df31c..9dd165f285 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -6110,6 +6110,13 @@ fn lower_imperative_clause(text: &str, ctx: &mut ParseContext) -> ParsedEffectCl if matches!(clause.effect, Effect::DealDamage { .. }) && clause.multi_target.is_none() { clause.multi_target = extract_deal_damage_multi_target(text); } + // CR 115.1d: Post-parse fixup for SwitchPT prepositional form. The + // imperative parser strips "any number of" / "each of" to keep `parse_target` + // bare, so the MultiTargetSpec is rebuilt from the original text here + // (parallel to the DealDamage / Double counter fixups above). + if matches!(clause.effect, Effect::SwitchPT { .. }) && clause.multi_target.is_none() { + clause.multi_target = extract_switch_pt_multi_target(text); + } if matches!( clause.effect, Effect::Double { @@ -14962,6 +14969,45 @@ fn extract_deal_damage_multi_target(text: &str) -> Option { multi_target } +/// CR 115.1d + CR 613.4d: Recover the `MultiTargetSpec` for the prepositional +/// SwitchPT form ("switch the power and toughness of "). The +/// imperative parser strips "each of" and "any number of" so `parse_target` +/// sees a bare target phrase; this helper rebuilds the spec from the original +/// text. Mirrors `extract_double_counter_multi_target` — the only axis of +/// variation is the verb prefix. +fn extract_switch_pt_multi_target(text: &str) -> Option { + let lower = text.to_lowercase(); + let (_, target_text) = preceded( + tag::<_, _, OracleError<'_>>("switch the power and toughness of "), + rest, + ) + .parse(lower.as_str()) + .ok()?; + // The distribution prefix "each of " is optional ("switch ... of each of + // any number of target creatures" vs "switch ... of any number of target + // creatures"); both surface the same MultiTargetSpec. + let after_each_of = tag::<_, _, OracleError<'_>>("each of ") + .parse(target_text) + .map(|(rest, _)| rest) + .unwrap_or(target_text); + if let Ok((after_any_number, _)) = + tag::<_, _, OracleError<'_>>("any number of ").parse(after_each_of) + { + if alt(( + tag::<_, _, OracleError<'_>>("target "), + tag("other target "), + tag("another target "), + )) + .parse(after_any_number) + .is_ok() + { + return Some(MultiTargetSpec::unlimited(0)); + } + } + let (_, multi_target) = strip_optional_target_prefix(after_each_of); + multi_target +} + fn extract_double_counter_multi_target(text: &str) -> Option { let lower = text.to_lowercase(); let (_, target_text) = preceded( @@ -32179,6 +32225,42 @@ mod tests { ); } + /// CR 613.4d: prepositional surface form ("switch the power and toughness + /// of target creature") — single-target sibling of the possessive form. + #[test] + fn effect_switch_pt_prepositional_single_target() { + let e = parse_effect("switch the power and toughness of target creature until end of turn"); + assert!( + matches!(e, Effect::SwitchPT { .. }), + "expected SwitchPT, got: {e:?}" + ); + } + + /// CR 613.4d + CR 115.1d: Inversion Behemoth class — "switch the power and + /// toughness of each of any number of target creatures" must parse to + /// `Effect::SwitchPT` carrying a typed creature filter, with + /// `MultiTargetSpec::unlimited(0)` recovered on the clause so the spell + /// allows any number of targets. + #[test] + fn effect_switch_pt_any_number_of_target_creatures_is_multi_targeted() { + let clause = parse_effect_clause( + "switch the power and toughness of each of any number of target creatures until end of turn", + &mut ParseContext::default(), + ); + + assert_eq!(clause.multi_target, Some(MultiTargetSpec::unlimited(0))); + assert!( + matches!( + clause.effect, + Effect::SwitchPT { + target: TargetFilter::Typed(_), + } + ), + "expected SwitchPT {{ target: Typed(..) }}, got: {:?}", + clause.effect + ); + } + /// CR 608.2c: Period-separated pronoun reference ("Tap target creature. Put two stun /// counters on it.") must resolve "it" to ParentTarget, not SelfRef. The sub_ability's /// counter effect should target the same creature as the tap effect. From 72d8bd8a91e0da3fbe94ad0d45836c19d356ce9c Mon Sep 17 00:00:00 2001 From: mike-theDude <53224244+mike-theDude@users.noreply.github.com> Date: Mon, 25 May 2026 23:52:32 +0200 Subject: [PATCH 20/22] Skip target slots for mass-broadcast effects Prevents TargetFilter::Any mass-broadcast effects from creating impossible trigger target prompts while preserving DealDamage's intentional any-target handling. Addresses Gemini's generalization comment and fixes the #824 freeze class. --- crates/engine/src/game/triggers.rs | 94 ++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 2d5564dab7..66987733b7 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -3532,6 +3532,27 @@ pub(crate) fn extract_target_filter_from_effect(effect: &Effect) -> Option<&Targ } } } + // CR 115.1 / CR 115.1d: Only effects that use the word "target" require stack-time target + // selection. `TargetFilter::Any` is a sentinel value meaning "broadcast to all + // matching permanents at resolution time" — it is never a declared target on any + // effect. The one exception is `DealDamage`, which uses `TargetFilter::Any` to + // represent the "any target" wording in damage-dealing spells and abilities (e.g. + // "deals 3 damage to any target"), where the player does choose a single target + // from the combined pool of creatures, planeswalkers, and players. + // + // For all other effects, `TargetFilter::Any` arises in two ways: (a) as a mass + // broadcast where the Oracle text contains no "target" keyword (e.g. "creatures + // get -N/-M until end of turn"), or (b) as an unthreaded subject sentinel produced + // by a sub-parser before the calling parser threads the real subject (SelfRef, + // ParentTarget, etc.). In both cases no player-chosen target is required. + // Generating a slot for `Any` causes a spurious WaitingFor::TriggerTargetSelection + // entry that players and the AI cannot resolve, producing a hard freeze (issue #824 + // class). + if effect.target_filter() == Some(&TargetFilter::Any) + && !matches!(effect, Effect::DealDamage { .. }) + { + return None; + } effect.target_filter().filter(|t| !t.is_context_ref()) } // --------------------------------------------------------------------------- @@ -8315,6 +8336,79 @@ pub mod tests { ); } + // === CR 115.1 / CR 115.1d: extract_target_filter mass-effect (Any-filter) tests === + + /// CR 115.1 / CR 115.1d: `Pump { target: Any }` is a mass broadcast effect — no word + /// "target" in Oracle text, so no stack-time target slot should be generated. + /// This is also used as a sentinel value by `try_parse_pump` before the + /// calling parser threads a real subject (issue #824 class). + #[test] + fn extract_target_skips_pump_with_any_filter() { + use crate::types::ability::PtValue; + let effect = Effect::Pump { + power: PtValue::Fixed(-2), + toughness: PtValue::Fixed(-2), + target: TargetFilter::Any, + }; + assert!( + extract_target_filter_from_effect(&effect).is_none(), + "Pump{{target: Any}} is a mass effect — must not generate a target slot" + ); + } + + /// CR 115.1 / CR 115.1d: `Pump { target: Typed(Creature) }` is a genuinely targeted + /// effect ("target creature gets +N/+M") — must still generate a slot. + #[test] + fn extract_target_keeps_pump_with_typed_filter() { + use crate::types::ability::PtValue; + let effect = Effect::Pump { + power: PtValue::Fixed(2), + toughness: PtValue::Fixed(2), + target: TargetFilter::Typed(TypedFilter::creature()), + }; + assert!( + extract_target_filter_from_effect(&effect).is_some(), + "Pump with a typed target filter must still generate a target slot" + ); + } + + /// CR 115.1 / CR 115.1d: `GenericEffect { target: Some(Any) }` is a mass continuous + /// modification ("each creature gets -2/-2 until end of turn") produced by + /// `build_layer_effect_until`. No word "target" in Oracle text, so no slot. + #[test] + fn extract_target_skips_generic_effect_with_any_filter() { + // StaticMode is imported below the test section; use fully qualified path. + let effect = Effect::GenericEffect { + static_abilities: vec![StaticDefinition::new( + crate::types::statics::StaticMode::Continuous, + )], + duration: Some(Duration::UntilEndOfTurn), + target: Some(TargetFilter::Any), + }; + assert!( + extract_target_filter_from_effect(&effect).is_none(), + "GenericEffect{{target: Some(Any)}} is a mass effect — must not generate a target slot (issue #824)" + ); + } + + /// CR 115.1 / CR 115.1d: `GenericEffect { target: Some(Typed(Creature)) }` is a + /// targeted continuous modification ("target creature gains haste") — must + /// still generate a slot. + #[test] + fn extract_target_keeps_generic_effect_with_typed_filter() { + let effect = Effect::GenericEffect { + static_abilities: vec![StaticDefinition::new( + crate::types::statics::StaticMode::Continuous, + )], + duration: Some(Duration::UntilEndOfTurn), + target: Some(TargetFilter::Typed(TypedFilter::creature())), + }; + assert!( + extract_target_filter_from_effect(&effect).is_some(), + "GenericEffect with a typed target must still generate a target slot" + ); + } + // === CR 603.2g + CR 603.6a + CR 700.4: SuppressTriggers integration tests === use crate::types::statics::{StaticMode, SuppressedTriggerEvent}; From 51e2743b39f007e5684ad030b0b616012dc7c6fb Mon Sep 17 00:00:00 2001 From: Whovencroft Date: Mon, 25 May 2026 18:05:35 -0400 Subject: [PATCH 21/22] Support evolved trigger events Adds parser/runtime support for "whenever this creature evolves" by separating the evolve keyword ETB trigger from the post-counter evolved event, covering Renegade Krasis and Watchful Radstag. --- crates/engine/src/database/synthesis.rs | 9 +-- crates/engine/src/game/ability_utils.rs | 1 + crates/engine/src/game/effects/counters.rs | 46 ++++++++++++++- crates/engine/src/game/log.rs | 6 ++ crates/engine/src/game/restrictions.rs | 1 + crates/engine/src/game/targeting.rs | 1 + crates/engine/src/game/trigger_matchers.rs | 22 ++++++- crates/engine/src/game/triggers.rs | 68 ++++++++++++++++++++++ crates/engine/src/parser/oracle_trigger.rs | 66 ++++++++++++++++++++- crates/engine/src/types/ability.rs | 6 ++ crates/engine/src/types/events.rs | 5 ++ crates/engine/src/types/triggers.rs | 6 +- 12 files changed, 227 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index 4d96ad1dd6..358a4aff84 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -1948,11 +1948,11 @@ fn is_annihilator_attack_trigger(t: &TriggerDefinition) -> bool { } /// Idempotency-shape predicate for `synthesize`-installed Evolve triggers. -/// `TriggerMode::Evolved` is unique to the Evolve keyword, so the mode alone +/// `TriggerMode::Evolve` is unique to the Evolve keyword, so the mode alone /// uniquely identifies a synthesized Evolve trigger — no execute-shape check /// is needed (or wanted: it would disambiguate nothing). fn is_evolve_trigger(t: &TriggerDefinition) -> bool { - matches!(t.mode, TriggerMode::Evolved) + matches!(t.mode, TriggerMode::Evolve) } fn is_myriad_attack_trigger(t: &TriggerDefinition) -> bool { @@ -2185,7 +2185,7 @@ fn build_annihilator_trigger(n: u32) -> TriggerDefinition { /// satisfied for free by `triggers_for` being invoked per keyword instance. fn build_evolve_trigger() -> TriggerDefinition { // CR 122.1: put a single +1/+1 counter on the Evolve creature itself. - let put_counter = AbilityDefinition::new( + let mut put_counter = AbilityDefinition::new( AbilityKind::Spell, Effect::PutCounter { counter_type: CounterType::Plus1Plus1, @@ -2194,6 +2194,7 @@ fn build_evolve_trigger() -> TriggerDefinition { }, ) .description("Put a +1/+1 counter on this creature".to_string()); + put_counter.ability_tag = Some(AbilityTag::Evolve); // CR 702.100a "and/or": fire if the entering creature's power is greater // OR its toughness is greater than this creature's. CR 603.4: this is the @@ -2234,7 +2235,7 @@ fn build_evolve_trigger() -> TriggerDefinition { // `valid_card` selects any creature the trigger controller controls // (including the Evolve creature itself — its self-vs-self P/T comparison // yields equal values, so the strict-`GT` intervening-if filters it out). - TriggerDefinition::new(TriggerMode::Evolved) + TriggerDefinition::new(TriggerMode::Evolve) .destination(Zone::Battlefield) .valid_card(TargetFilter::Typed( TypedFilter::creature().controller(ControllerRef::You), diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index 733519d0aa..f3435b26f3 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -40,6 +40,7 @@ pub fn build_resolved_from_def_with_targets( ) -> ResolvedAbility { let mut resolved = ResolvedAbility::new(*def.effect.clone(), targets, source_id, controller).kind(def.kind); + resolved.context.ability_tag = def.ability_tag; if let Some(sub) = &def.sub_ability { resolved = resolved.sub_ability(build_resolved_from_def(sub, source_id, controller)); } diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index a86acb4c45..864fc88915 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -3,7 +3,8 @@ use std::collections::HashSet; use crate::game::game_object::GameObject; use crate::game::replacement::{self, ReplacementResult}; use crate::types::ability::{ - CounterTransferMode, Effect, EffectError, EffectKind, ResolvedAbility, TargetFilter, TargetRef, + AbilityTag, CounterTransferMode, Effect, EffectError, EffectKind, ResolvedAbility, + TargetFilter, TargetRef, }; #[cfg(test)] use crate::types::counter::parse_counter_type; @@ -323,6 +324,7 @@ pub fn resolve_add( if let Some(distribution) = &ability.distribution { for (target, count) in distribution { if let crate::types::ability::TargetRef::Object(obj_id) = target { + let event_start = events.len(); add_counter_with_replacement( state, ability.controller, @@ -331,11 +333,19 @@ pub fn resolve_add( *count, events, ); + emit_evolved_event_for_counter_addition( + ability, + events, + event_start, + *obj_id, + &counter_type, + ); } } } else { let targets = resolve_defined_or_targets(state, ability); for obj_id in targets { + let event_start = events.len(); add_counter_with_replacement( state, ability.controller, @@ -344,6 +354,13 @@ pub fn resolve_add( counter_num, events, ); + emit_evolved_event_for_counter_addition( + ability, + events, + event_start, + obj_id, + &counter_type, + ); } } @@ -355,6 +372,33 @@ pub fn resolve_add( Ok(()) } +fn emit_evolved_event_for_counter_addition( + ability: &ResolvedAbility, + events: &mut Vec, + event_start: usize, + object_id: ObjectId, + counter_type: &CounterType, +) { + if ability.context.ability_tag != Some(AbilityTag::Evolve) + || *counter_type != CounterType::Plus1Plus1 + { + return; + } + let evolved = events[event_start..].iter().any(|event| { + matches!( + event, + GameEvent::CounterAdded { + object_id: added_to, + counter_type: CounterType::Plus1Plus1, + count + } if *added_to == object_id && *count > 0 + ) + }); + if evolved { + events.push(GameEvent::Evolved { object_id }); + } +} + /// CR 122.1: Place counters on all battlefield objects matching a filter (no targeting). pub fn resolve_add_all( state: &mut GameState, diff --git a/crates/engine/src/game/log.rs b/crates/engine/src/game/log.rs index 8652bacb47..40fd2adc75 100644 --- a/crates/engine/src/game/log.rs +++ b/crates/engine/src/game/log.rs @@ -141,6 +141,7 @@ fn categorize(event: &GameEvent) -> LogCategory { | GameEvent::PlayerPhasedIn { .. } | GameEvent::DamageCleared { .. } | GameEvent::CounterAdded { .. } + | GameEvent::Evolved { .. } | GameEvent::CounterRemoved { .. } | GameEvent::Transformed { .. } | GameEvent::TurnedFaceUp { .. } @@ -281,6 +282,7 @@ fn format_segments(event: &GameEvent, state: &GameState) -> Vec { } => { let label = match ability_tag { AbilityTag::Boast => " activates boast: ", + AbilityTag::Evolve => " activates evolve: ", AbilityTag::Exhaust => " activates exhaust: ", AbilityTag::Outlast => " activates outlast: ", }; @@ -607,6 +609,10 @@ fn format_segments(event: &GameEvent, state: &GameState) -> Vec { card_seg(state, *object_id), ], + GameEvent::Evolved { object_id } => { + vec![card_seg(state, *object_id), text(" evolved")] + } + GameEvent::CounterRemoved { object_id, counter_type, diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs index 148b6cdacd..be0d9f1c76 100644 --- a/crates/engine/src/game/restrictions.rs +++ b/crates/engine/src/game/restrictions.rs @@ -530,6 +530,7 @@ fn effective_activation_limit( }; let keyword = match tag { AbilityTag::Boast => "boast", + AbilityTag::Evolve => "evolve", AbilityTag::Exhaust => "exhaust", AbilityTag::Outlast => "outlast", }; diff --git a/crates/engine/src/game/targeting.rs b/crates/engine/src/game/targeting.rs index 11057735ca..f31fbbf4c8 100644 --- a/crates/engine/src/game/targeting.rs +++ b/crates/engine/src/game/targeting.rs @@ -679,6 +679,7 @@ pub(crate) fn extract_source_from_event( // trigger fires from; `source_id` is the permanent tapped for mana. GameEvent::TappedForMana { source_id, .. } => Some(*source_id), GameEvent::CounterAdded { object_id, .. } => Some(*object_id), + GameEvent::Evolved { object_id } => Some(*object_id), GameEvent::CounterRemoved { object_id, .. } => Some(*object_id), GameEvent::TokenCreated { object_id, .. } => Some(*object_id), GameEvent::CreatureDestroyed { object_id } => Some(*object_id), diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index 91744fd1d4..9164f41cde 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -20,7 +20,8 @@ pub fn trigger_matcher(mode: TriggerMode) -> Option { // .destination(Battlefield); valid_card filtering and the power/toughness // intervening-if (CR 603.4) are handled downstream by // zone_change_clause_matches / check_trigger_condition respectively. - TriggerMode::ChangesZone | TriggerMode::Evolved => match_changes_zone, + TriggerMode::ChangesZone | TriggerMode::Evolve => match_changes_zone, + TriggerMode::Evolved => match_evolved, TriggerMode::ChangesZoneAll => match_changes_zone_all, TriggerMode::DamageDone | TriggerMode::DamageDoneOnce @@ -361,6 +362,7 @@ pub fn build_trigger_registry() -> HashMap { TriggerMode::Exerted, // TriggerMode::Crewed — moved to real matcher below // TriggerMode::Saddled — moved to real matcher below + // TriggerMode::Evolve — moved to real matcher below // TriggerMode::Evolved — moved to real matcher below TriggerMode::Enlisted, TriggerMode::Adapt, @@ -406,7 +408,10 @@ pub fn build_trigger_registry() -> HashMap { // .destination(Battlefield); valid_card filtering and the power/toughness // intervening-if (CR 603.4) are handled downstream by // zone_change_clause_matches / check_trigger_condition respectively. - r.insert(TriggerMode::Evolved, match_changes_zone); + r.insert(TriggerMode::Evolve, match_changes_zone); + // CR 702.100b: "Whenever [a creature] evolves" fires only when the + // evolve ability's resolution actually put one or more +1/+1 counters on it. + r.insert(TriggerMode::Evolved, match_evolved); // CR 702.122d: Crew trigger matchers r.insert(TriggerMode::Crewed, match_vehicle_crewed); @@ -1173,6 +1178,19 @@ pub(super) fn match_counter_added( } } +pub(super) fn match_evolved( + event: &GameEvent, + trigger: &TriggerDefinition, + source_id: ObjectId, + state: &GameState, +) -> bool { + if let GameEvent::Evolved { object_id } = event { + valid_card_matches(trigger, state, *object_id, source_id) + } else { + false + } +} + pub(super) fn match_counter_removed( event: &GameEvent, trigger: &TriggerDefinition, diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 66987733b7..779414009f 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -4219,6 +4219,74 @@ pub mod tests { ); } + /// CR 702.100b: A creature "evolves" only after its evolve ability + /// resolves and actually puts one or more +1/+1 counters on it. This is + /// distinct from the CR 702.100a ETB trigger event that starts the evolve + /// ability. + #[test] + fn test_evolved_trigger_fires_after_evolve_counter_is_added() { + let mut state = setup(); + state.active_player = PlayerId(0); + state.priority_player = PlayerId(0); + let evolver = make_evolve_creature(&mut state, PlayerId(0), "Evolve 2/2", 2, 2); + let entrant = make_creature(&mut state, PlayerId(0), "Bigger 3/3", 3, 3); + + let draw = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + let evolved_trigger = TriggerDefinition::new(TriggerMode::Evolved) + .valid_card(TargetFilter::SelfRef) + .execute(draw) + .description("Whenever this creature evolves, draw a card.".to_string()); + state + .objects + .get_mut(&evolver) + .unwrap() + .trigger_definitions + .push(evolved_trigger.clone()); + std::sync::Arc::make_mut( + &mut state + .objects + .get_mut(&evolver) + .unwrap() + .base_trigger_definitions, + ) + .push(evolved_trigger); + + process_triggers( + &mut state, + &[zone_changed_event( + entrant, + Zone::Stack, + Zone::Battlefield, + vec![CoreType::Creature], + vec![], + )], + ); + assert_eq!(state.stack.len(), 1, "only the evolve ETB trigger fires"); + + let mut events = Vec::new(); + crate::game::stack::resolve_top(&mut state, &mut events); + assert!( + events.iter().any(|event| matches!( + event, + GameEvent::Evolved { object_id } if *object_id == evolver + )), + "evolve resolution must emit the CR 702.100b evolved event" + ); + + process_triggers(&mut state, &events); + assert_eq!( + state.stack.len(), + 1, + "the separate 'whenever this evolves' trigger must now fire" + ); + } + /// CR 702.100a: a creature whose power AND toughness are both not greater /// (smaller, or equal) does NOT trigger Evolve — the intervening-if uses /// strict greater-than, not greater-or-equal. diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index b1d163d740..3a7c5b990a 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -3579,12 +3579,23 @@ fn parse_event_verb_start(input: &str) -> OracleResult<'_, ()> { parse_event_phrase("create "), parse_event_word("create"), )); + let simple_event_verbs = alt(( + // CR 702.100b + CR 701.44b: SimpleEvent verbs that may appear in + // compound triggers (e.g. "~ evolves or dies"). + parse_event_word("evolves"), + parse_event_phrase("evolve "), + parse_event_word("explores"), + parse_event_phrase("explore "), + parse_event_word("exploits"), + parse_event_word("mutates"), + parse_event_word("transforms"), + )); let player_actions = alt(( passive_player_actions, sacrifice_discard_actions, play_cast_create_actions, )); - alt((combat_or_zone, player_actions)).parse(input) + alt((combat_or_zone, player_actions, simple_event_verbs)).parse(input) } fn parse_bare_shared_event_verb(input: &str) -> OracleResult<'_, ()> { @@ -5016,6 +5027,9 @@ fn try_parse_event( Exploits, /// CR 701.44b: A permanent "explores" after the explore process completes. Explores, + /// CR 702.100b: A creature "evolves" when +1/+1 counters are put on it + /// as a result of its evolve ability resolving. + Evolves, Transforms, Stations, SaddlesOrCrews, @@ -5082,6 +5096,9 @@ fn try_parse_event( // CR 701.44b: "explores" / "explore" — explore trigger value(SimpleEvent::Explores, tag("explores")), value(SimpleEvent::Explores, tag("explore")), + // CR 702.100b: "evolves" / "evolve" — evolve trigger + value(SimpleEvent::Evolves, tag("evolves")), + value(SimpleEvent::Evolves, tag("evolve")), // CR 712.14: "transforms" / "transforms into" value(SimpleEvent::Transforms, tag("transforms")), // CR 702.184a: "stations ~" — actor-side Station trigger. @@ -5170,6 +5187,15 @@ fn try_parse_event( def.mode = TriggerMode::Explored; def.valid_card = Some(subject.clone()); } + SimpleEvent::Evolves => { + if !remaining.trim().is_empty() { + return None; + } + // CR 702.100b: "evolves" fires when +1/+1 counters are put on + // the creature as a result of its evolve ability resolving. + def.mode = TriggerMode::Evolved; + def.valid_card = Some(subject.clone()); + } SimpleEvent::Transforms => { def.mode = TriggerMode::Transformed; def.valid_source = Some(subject.clone()); @@ -10178,8 +10204,44 @@ mod tests { ); } + #[test] + fn trigger_evolves_self() { + let def = parse_trigger_line( + "Whenever ~ evolves, put a +1/+1 counter on each other creature you control with a +1/+1 counter on it.", + "Renegade Krasis", + ); + assert_eq!(def.mode, TriggerMode::Evolved); + assert_eq!(def.valid_card, Some(TargetFilter::SelfRef)); + } + #[test] + fn trigger_evolves_creature_you_control() { + let def = parse_trigger_line( + "Whenever a creature you control evolves, draw a card.", + "Test Card", + ); + assert_eq!(def.mode, TriggerMode::Evolved); + assert_eq!( + def.valid_card, + Some(TargetFilter::Typed( + TypedFilter::creature().controller(crate::types::ability::ControllerRef::You) + )) + ); + } + #[test] + fn trigger_evolve_plural() { + let def = parse_trigger_line( + "Whenever one or more creatures you control evolve, draw a card.", + "Test Card", + ); + assert_eq!(def.mode, TriggerMode::Evolved); + assert_eq!( + def.valid_card, + Some(TargetFilter::Typed( + TypedFilter::creature().controller(crate::types::ability::ControllerRef::You) + )) + ); + } // --- Subject decomposition tests --- - #[test] fn trigger_another_creature_you_control_enters() { let def = parse_trigger_line( diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 344818843b..bb93a4baba 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -7982,6 +7982,8 @@ impl<'de> Deserialize<'de> for ModalSelectionCondition { pub enum AbilityTag { /// CR 702.142a: This ability originated from a Boast keyword definition. Boast, + /// CR 702.100a: This ability originated from an Evolve keyword definition. + Evolve, /// CR 702.177a: This ability originated from an Exhaust keyword definition. Exhaust, /// CR 702.107a: This ability originated from an Outlast keyword definition. @@ -8994,6 +8996,10 @@ pub struct SpellContext { /// the resolution-time battlefield. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub controller_controlled_as_cast: Vec, + /// CR 702: Keyword origin carried from the parsed/synthesized definition + /// into runtime resolution for keyword-specific events. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ability_tag: Option, } impl SpellContext { diff --git a/crates/engine/src/types/events.rs b/crates/engine/src/types/events.rs index ca26d73dd4..849102e9a9 100644 --- a/crates/engine/src/types/events.rs +++ b/crates/engine/src/types/events.rs @@ -322,6 +322,11 @@ pub enum GameEvent { counter_type: CounterType, count: u32, }, + /// CR 702.100b: A creature evolved because one or more +1/+1 counters were + /// put on it as a result of its evolve ability resolving. + Evolved { + object_id: ObjectId, + }, CounterRemoved { object_id: ObjectId, counter_type: CounterType, diff --git a/crates/engine/src/types/triggers.rs b/crates/engine/src/types/triggers.rs index ca88593195..2a2a309564 100644 --- a/crates/engine/src/types/triggers.rs +++ b/crates/engine/src/types/triggers.rs @@ -216,7 +216,9 @@ pub enum TriggerMode { /// (e.g., "an ability of an artifact source") live on `valid_card` — both /// reuse existing infrastructure shared with `KeywordAbilityActivated`. AbilityActivated, - /// CR 702.100: Evolve trigger — when a creature enters with greater power/toughness. + /// CR 702.100a: Evolve keyword trigger — when a creature enters with greater power/toughness. + Evolve, + /// CR 702.100b: Triggers when a creature evolves. Evolved, /// CR 701.44: Triggers when a creature explores. Explored, @@ -434,6 +436,7 @@ impl FromStr for TriggerMode { "Enlisted" => TriggerMode::Enlisted, "AttacksOrBlocks" => TriggerMode::AttacksOrBlocks, "EntersOrAttacks" => TriggerMode::EntersOrAttacks, + "Evolve" => TriggerMode::Evolve, "Evolved" => TriggerMode::Evolved, "ExcessDamage" => TriggerMode::ExcessDamage, "ExcessDamageAll" => TriggerMode::ExcessDamageAll, @@ -697,6 +700,7 @@ mod tests { "ElementalBend", "Enlisted", "EntersOrAttacks", + "Evolve", "Evolved", "ExcessDamage", "ExcessDamageAll", From c9ddd23b35bca82fe10c52245324ecc177322286 Mon Sep 17 00:00:00 2001 From: "Erik (via Manus)" Date: Mon, 25 May 2026 22:45:26 +0000 Subject: [PATCH 22/22] fix: filter modal modes with no legal targets before presenting choice CR 700.2d: When a modal triggered or activated ability is about to present a mode choice, each mode's targeting requirements are now checked against the current board state. Modes whose targeting slots cannot be legally satisfied are added to unavailable_modes before the choice is presented to the player/AI. This prevents the softlock where: 1. A modal trigger fires (e.g. 'choose one' with a targeting mode) 2. The player selects a mode that requires targets 3. pending_trigger is consumed 4. build_target_slots fails because no legal targets exist 5. The game state becomes irrecoverable The fix adds filter_modes_by_target_legality() in ability_utils.rs and calls it at both modal trigger (engine.rs) and modal activated ability (casting.rs) sites, immediately after compute_unavailable_modes(). Fixes #840 --- crates/engine/src/game/ability_utils.rs | 53 +++++++++++++++++++++++++ crates/engine/src/game/casting.rs | 10 ++++- crates/engine/src/game/engine.rs | 15 +++++-- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index f3435b26f3..222b4129f7 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -365,6 +365,59 @@ pub fn compute_unavailable_modes( unavailable } +/// CR 700.2d: Extends `unavailable_modes` with mode indices whose targeting +/// requirements cannot be satisfied on the current board. For each mode not +/// already marked unavailable, builds the resolved ability for that single mode, +/// computes its target slots, and checks whether a legal target assignment +/// exists. Modes that require targets but have no legal assignment are appended +/// to `unavailable_modes`. +/// +/// This prevents the softlock where a player (or AI) selects a mode with no +/// legal targets, causing `pending_trigger` to be consumed and then the +/// targeting step to fail irrecoverably. +pub fn filter_modes_by_target_legality( + state: &GameState, + source_id: ObjectId, + controller: PlayerId, + mode_abilities: &[AbilityDefinition], + modal: &ModalChoice, + unavailable_modes: &mut Vec, +) { + let target_constraints = target_constraints_from_modal(modal); + for mode_idx in 0..modal.mode_count { + if unavailable_modes.contains(&mode_idx) { + continue; + } + let Some(def) = mode_abilities.get(mode_idx) else { + continue; + }; + let resolved = build_resolved_from_def(def, source_id, controller); + let target_slots = match build_target_slots(state, &resolved) { + Ok(slots) => slots, + Err(_) => { + // build_target_slots returns Err when no legal targets exist + // for a required targeting slot — mark mode unavailable. + unavailable_modes.push(mode_idx); + continue; + } + }; + // A mode with no target slots does not require targeting — always legal. + if target_slots.is_empty() { + continue; + } + if !has_legal_target_assignment_for_ability( + state, + &resolved, + &target_slots, + &target_constraints, + ) { + unavailable_modes.push(mode_idx); + } + } + unavailable_modes.sort_unstable(); + unavailable_modes.dedup(); +} + /// Records chosen mode indices for NoRepeat constraint enforcement. /// CR 700.2: Inserts into per-turn and/or per-game tracking maps. pub fn record_modal_mode_choices( diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 77b703bbeb..1d23c3b971 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -7158,7 +7158,15 @@ pub fn handle_activate_ability( )); } } - let unavailable_modes = compute_unavailable_modes(state, source_id, &modal); + let mut unavailable_modes = compute_unavailable_modes(state, source_id, &modal); + super::ability_utils::filter_modes_by_target_legality( + state, + source_id, + player, + &ability_def.mode_abilities, + &modal, + &mut unavailable_modes, + ); // CR 700.2a / CR 700.2e: `AbilityModeChoice.player` is threaded // downstream as the activated ability's controller (cost payment, // stack `controller`, target selection — see `engine_modes.rs`), so diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index fb6fb6261b..aee65a857c 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -4027,10 +4027,19 @@ pub(super) fn begin_pending_trigger_target_selection( modal, &crate::types::ability::SpellContext::default(), ); - let unavailable_modes = compute_unavailable_modes(state, trigger.source_id, &modal); + let mut unavailable_modes = compute_unavailable_modes(state, trigger.source_id, &modal); + super::ability_utils::filter_modes_by_target_legality( + state, + trigger.source_id, + trigger.controller, + &trigger.mode_abilities, + &modal, + &mut unavailable_modes, + ); - // CR 700.2: All modes already chosen — ability cannot be put on the stack - // without a mode selection. Clear pending trigger and skip. + // CR 700.2d: All modes unavailable (previously chosen OR no legal + // targets) — ability cannot be put on the stack. Clear pending + // trigger and skip. if unavailable_modes.len() >= modal.mode_count { state.pending_trigger = None; return Ok(None);