diff --git a/docs/card-coverage-progress.md b/docs/card-coverage-progress.md new file mode 100644 index 0000000..2934260 --- /dev/null +++ b/docs/card-coverage-progress.md @@ -0,0 +1,46 @@ +# Card coverage checkpoint — 2026-10-20 + +This checkpoint distinguishes **recognized text** from **behavior tested in the engine**. It is not a claim that every recognized card is fully playable or that the card-coverage task is complete. See [the continuation plan](rules-engine-continuation.md) for architectural limits and ownership boundaries. + +## Reproducible corpus + +- Baseline code: `9781fa91765702206f1ebbd681a1221ff6a72bb2` (main). +- Public source: , downloaded 2026-10-20. +- Database metadata: date `2026-10-19`, version `5.3.0+20261019`. +- Download archive SHA-256: `57a22f9820af958e9bf9974f375ddb6554796769b7a123d8a6773b49c21976c8`. +- Extracted SQLite SHA-256: `0b1eb20e43a709a92cfe23a946e75d9b44b05d480878cc6623e96c69743b946f`. +- The unchanged importer selected 183 sets and wrote 181 nonempty pools plus 858 rental-deck files. Booster eligibility, printing deduplication, front/back handling, and Modern legality use the existing importer/report rules. “All” below means all imported pools, **not every card in the full database**. +- Tests and coverage reports used the downloaded local corpus. Automated tests did not contact Discord, MTGJSON, or Scryfall. No live bot, deployment, release, or token-bearing build was run. + +Use `MTGJSON_DATABASE` to point to the extracted file and `MTGPOCKET_DATA` to a scratch directory. Run `php bin/import-cards.php`, followed by `php bin/card-coverage.php 60 all` and `php bin/card-coverage.php 60 modern`. Pin the database hashes when reproducing these numbers; the public URL is mutable. The baseline was measured in a separate immutable checkout against the same imported pools. + +## Recognition delta + +| Imported scope | Baseline read whole | With this batch | +| --- | --- | --- | +| Modern | 9,514 / 22,399 (42.5%) | 9,517 / 22,399 (42.5%) | +| All imported pools | 11,254 / 28,729 (39.2%) | 11,257 / 28,729 (39.2%) | + +The three newly wholly recognized pool entries are **Secrets of the Dead**, **Burning Vengeance**, and **Devoted Grafkeeper // Departed Soulkeeper**. The attacking-counter warning fix adds no recognition coverage. Historical percentages in the continuation plan used older code/data and are not this baseline. + +## Implemented behavior + +### Graveyard-cast triggers + +The exact clause “Whenever you cast a spell from your graveyard, …” uses the existing effect/trigger system. Casting captures the actual origin before the spell moves to the stack, rather than inferring it from flashback payment or from not having been cast from hand. Only the caster's permanents see this event. Ordinary copies, graveyard activations, hand casts, and exile casts do not create it. + +[GraveyardCastTriggerTest](../tests/Game/GraveyardCastTriggerTest.php) checks real wording, flashback, retrace, controller restrictions, payment/target rejection without mutation, independent trigger resolution after countering, target choice and save/resume, target incarnation changes, and spell-copy distinctions. Triggered effects continue to use existing engine decisions and targeting rules. This does not add new casting permissions, new persistence fields, or a new rules system. + +Primary wording and rules notes: [Wizards' Innistrad Remastered release notes, page 18](https://media.wizards.com/2024/downloads/INR_Release_Notes_hzvmSd7KJG/EN_MTGINR_ReleaseNotes_20240903.pdf) explain Burning Vengeance's ordering and why unearth/Reassembling Skeleton activations do not trigger it. + +### Attacking-counter parsing + +“Put a +1/+1 counter on each attacking creature” omitted the optional “other” regex capture. Reading actual corpus cards previously raised an undefined-array-key warning, which failed the imported-card test. The parser now defaults that absent qualifier to false. [AttackingCounterTextTest](../tests/Game/AttackingCounterTextTest.php) uses Fangren Firstborn's wording and verifies that both the source and another attacker receive counters after save/resume, while nonattackers do not. The pre-existing “each other attacking creature” regression remains covered separately. + +## Remaining work + +Most imported entries still have unrecognized text: 17,472 of 28,729 across all pools and 12,882 of 22,399 Modern-legal entries. Even the recognized subset needs behavior evidence, rather than an automatic “supported” label. + +This batch deliberately does not implement combined enter/cast clauses such as Ominous Roost, arbitrary alternate-source casts, player-selected type/color/devour decisions, multiplayer mechanics, or comprehensive rules correctness. The top residual report includes delayed upkeep draws, monarch/initiative, fading/phasing, library selection, copy retargeting, and unsupported combat/token restrictions; these need coherent engine work and negative/save-resume tests, not blanket regex acceptance. Commander/deck-construction-only wording must be distinguished from in-game effects before choosing another family. + +PR #124 is merged. At this checkpoint, open [PR #327](https://github.com/Valgorithms/DiscordPHP-MTGPocket/pull/327), head `3378473689d14d705b5bbc6e82c3ebbb291d2dcb`, owns enchanted/equipped conditional bonuses and keywords. This batch does not change that family. Refresh upstream and ownership again before continuing it. diff --git a/docs/rules-engine-continuation.md b/docs/rules-engine-continuation.md index bf2ef73..2837068 100644 --- a/docs/rules-engine-continuation.md +++ b/docs/rules-engine-continuation.md @@ -57,3 +57,7 @@ PR #125 adds **zero whole-card coverage**. It accepts the uncontracted two-sente [PR #124](https://github.com/Valgorithms/DiscordPHP-MTGPocket/pull/124), branch `claude/card-coverage-gaps-sc84tj`, head `6ba030f8a6844f4a11b52b677d142c34399a4853`, covers chosen creature types and typed team pumps. Claude ownership was unresolved at review time: preserve its branch and treat it as potentially owned. Refresh ownership and upstream before touching that family; do not assume Claude was stopped or its parent session migrated. Use descriptive branches and open PRs, preserve unrelated local files, and distinguish recognized text from functional gameplay. This continuation plan authorizes neither runtime refactoring by itself nor live bot startup, token-bearing builds, releases or deployment. Follow the current human task authorization and repository workflow. + +## Current coverage continuation + +The [2026-10-20 coverage checkpoint](card-coverage-progress.md) records a fresh, hashed public corpus, the current recognition baseline, tested graveyard-cast behavior, and remaining scope. It also refreshes the older ownership status above: #124 is merged, while #327 covers enchanted/equipped conditional bonuses and keywords. Recognition percentages remain separate from gameplay support. diff --git a/src/MTGPocket/Game/Game.php b/src/MTGPocket/Game/Game.php index 72c6928..8b79f6c 100644 --- a/src/MTGPocket/Game/Game.php +++ b/src/MTGPocket/Game/Game.php @@ -2414,7 +2414,9 @@ public function cast(int $seat, int $id, int $x = 0, array $targets = [], string $this->discardCheapest($seat, $wardDiscard, $id); $player = $this->players[$seat]; - $fromCommand = $object->zone === GameObject::COMMAND; + // Cast triggers need the actual origin, not the zone after moving or the payment method. + $castFrom = $object->zone; + $fromCommand = $castFrom === GameObject::COMMAND; $fromHand = $object->zone === GameObject::HAND; if ($fromCommand) { $tax = $this->commanderTax($id); @@ -2478,7 +2480,7 @@ public function cast(int $seat, int $id, int $x = 0, array $targets = [], string if ($conspirators !== null) { $this->copySpell(end($this->stack), $seat, 'conspire'); } - $this->castTriggers($seat, $object); + $this->castTriggers($seat, $object, $castFrom); $this->targetedTriggers($targets, $seat, true); $this->settle(); } @@ -2992,10 +2994,11 @@ private function weakest(int $seat, callable $filter): ?GameObject * * @param int $seat * @param GameObject $spell + * @param string $from The zone the spell was cast from, before moving to the stack. * * @return void */ - private function castTriggers(int $seat, GameObject $spell): void + private function castTriggers(int $seat, GameObject $spell, string $from): void { $card = $spell->definition(); if ($this->spellsTurn !== $this->turn) { @@ -3030,7 +3033,7 @@ private function castTriggers(int $seat, GameObject $spell): void if (! $card->isCreature()) { $caster->noncreatureCastTurn = $this->turn; } - $events = [...($caster->cast === 1 ? ['first_spell', ...($this->active !== $seat ? ['first_spell_opponent_turn'] : [])] : []), ...($caster->cast === 2 ? ['second_spell'] : []), ...($caster->cast === 3 ? ['third_spell'] : []), 'cast_spell', ...($card->isCreature() ? ['cast_creature'] : ['cast_noncreature']), ...($card->is('Artifact') ? ['cast_artifact'] : []), ...($card->is('Enchantment') ? ['cast_enchantment'] : []), ...($card->isPermanentCard() ? [] : ['cast_instant_sorcery']), + $events = [...($from === GameObject::GRAVEYARD ? ['cast_from_graveyard'] : []), ...($caster->cast === 1 ? ['first_spell', ...($this->active !== $seat ? ['first_spell_opponent_turn'] : [])] : []), ...($caster->cast === 2 ? ['second_spell'] : []), ...($caster->cast === 3 ? ['third_spell'] : []), 'cast_spell', ...($card->isCreature() ? ['cast_creature'] : ['cast_noncreature']), ...($card->is('Artifact') ? ['cast_artifact'] : []), ...($card->is('Enchantment') ? ['cast_enchantment'] : []), ...($card->isPermanentCard() ? [] : ['cast_instant_sorcery']), ...(array_intersect(['Spirit', 'Arcane'], $card->subtypes) !== [] ? ['cast_spirit_arcane'] : []), ...array_map(fn (string $color) => "cast_color_{$color}", $card->colors), ...(count($card->colors) > 1 ? ['cast_multicolored'] : [])]; $this->castSpell = $spell->id; diff --git a/src/MTGPocket/Game/TextParser.php b/src/MTGPocket/Game/TextParser.php index e4691ff..18f8d85 100644 --- a/src/MTGPocket/Game/TextParser.php +++ b/src/MTGPocket/Game/TextParser.php @@ -2523,7 +2523,7 @@ private static function triggered(string $line, array &$result): bool 'a noncreature spell' => 'cast_noncreature', 'an instant or sorcery spell' => 'cast_instant_sorcery', 'a spell' => 'cast_spell', 'a Spirit or Arcane spell' => 'cast_spirit_arcane', 'a creature spell' => 'cast_creature', 'an artifact spell' => 'cast_artifact', 'an enchantment spell' => 'cast_enchantment', 'a white spell' => 'cast_color_W', 'a blue spell' => 'cast_color_U', 'a black spell' => 'cast_color_B', 'a red spell' => 'cast_color_R', 'a green spell' => 'cast_color_G', - 'a multicolored spell' => 'cast_multicolored', + 'a multicolored spell' => 'cast_multicolored', 'a spell from your graveyard' => 'cast_from_graveyard', ]; // Haunt: `When CARDNAME enters or the creature it haunts dies, …` and `When the creature CARDNAME haunts dies, …` if (preg_match('/^When (CARDNAME enters or the creature it haunts dies|the creature CARDNAME haunts dies), (.+)\.$/', $line, $m) && ($effects = self::effects($m[2].'.')) !== null) { @@ -4580,7 +4580,7 @@ public static function effect(string $sentence): ?array } // `Put a +1/+1 counter on each other attacking creature.`: whoever controls them. if (preg_match('/^[Pp]ut (\w+) \+1\/\+1 counters? on each (other )?attacking creature$/', ucfirst($s), $m) && is_int($n = self::amount(strtolower($m[1])))) { - return ['type' => 'counters', 'amount' => $n, 'each' => 'attacking'] + ($m[2] !== '' ? ['other' => true] : []); + return ['type' => 'counters', 'amount' => $n, 'each' => 'attacking'] + (($m[2] ?? '') !== '' ? ['other' => true] : []); } // A ritual: `Add {R}{R}{R}.` if (preg_match('/^add ((\{[WUBRGC]\})\2*)$/i', $s, $m)) { diff --git a/tests/Game/AttackingCounterTextTest.php b/tests/Game/AttackingCounterTextTest.php new file mode 100644 index 0000000..81d72a8 --- /dev/null +++ b/tests/Game/AttackingCounterTextTest.php @@ -0,0 +1,35 @@ +newGame(); + $card = ['uuid' => 'fangren-firstborn', 'name' => 'Fangren Firstborn', 'manaCost' => '{1}{G}{G}{G}', 'type' => 'Creature — Beast', 'power' => '4', 'toughness' => '2', 'text' => 'Whenever this creature attacks, put a +1/+1 counter on each attacking creature.']; + $this->assertSame([], (new CardDefinition($card))->unsupported); + $source = $game->addCard(0, $card, GameObject::BATTLEFIELD)->id; + $attacker = $this->battlefield(0, 'Grizzly Bears'); + $idle = $this->battlefield(0, 'Hill Giant'); + $opponent = $this->battlefield(1, 'Grizzly Bears'); + $this->passUntil(Step::DeclareAttackers, 3); + $game->declareAttackers(0, [$source, $attacker]); + $this->assertCount(1, $game->stack); + $this->game = $game = Game::fromArray(json_decode(json_encode($game->toArray()), true)); + $game->pass($game->priority); + $game->pass($game->priority); + $this->assertSame([1, 1, 0, 0], array_map(fn (int $id) => $game->objects[$id]->counter('+1/+1'), [$source, $attacker, $idle, $opponent])); + } +} diff --git a/tests/Game/GraveyardCastTriggerTest.php b/tests/Game/GraveyardCastTriggerTest.php new file mode 100644 index 0000000..332c129 --- /dev/null +++ b/tests/Game/GraveyardCastTriggerTest.php @@ -0,0 +1,436 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace MTGPocket\Tests\Game; + +use MTGPocket\Game\CardDefinition; +use MTGPocket\Game\Game; +use MTGPocket\Game\GameException; +use MTGPocket\Game\GameObject; +use MTGPocket\Game\Step; + +/** + * Graveyard casts, not abilities or copies, trigger their controller's permanents. + * + * @covers \MTGPocket\Game\CardDefinition + * @covers \MTGPocket\Game\Game + * @covers \MTGPocket\Game\TextParser + */ +final class GraveyardCastTriggerTest extends GameTestCase +{ + /** Real card wording, kept offline so tests never require an imported pool. */ + private const array MORE = [ + 'Secrets of the Dead' => ['manaCost' => '{2}{U}', 'type' => 'Enchantment', 'colors' => ['U'], 'text' => 'Whenever you cast a spell from your graveyard, draw a card.'], + 'Burning Vengeance' => ['manaCost' => '{2}{R}', 'type' => 'Enchantment', 'colors' => ['R'], 'text' => 'Whenever you cast a spell from your graveyard, this enchantment deals 2 damage to any target.'], + 'Devoted Grafkeeper // Departed Soulkeeper' => ['manaCost' => '{W}{U}', 'type' => 'Creature — Human Peasant', 'colors' => ['W', 'U'], 'power' => '2', 'toughness' => '1', 'layout' => 'transform', + 'text' => "When this creature enters, mill two cards.\nWhenever you cast a spell from your graveyard, tap target creature you don't control.\nDisturb {1}{W}{U} (You may cast this card from your graveyard transformed for its disturb cost.)", + 'back' => ['name' => 'Departed Soulkeeper', 'manaCost' => null, 'type' => 'Creature — Spirit', 'colors' => ['W', 'U'], 'power' => '3', 'toughness' => '1', 'text' => "Flying\nThis creature can block only creatures with flying.\nIf Departed Soulkeeper would be put into a graveyard from anywhere, exile it instead."]], + 'Think Twice' => ['manaCost' => '{1}{U}', 'type' => 'Instant', 'colors' => ['U'], 'text' => "Draw a card.\nFlashback {2}{U} (You may cast this card from your graveyard for its flashback cost. Then exile it.)"], + 'Flame Jab' => ['manaCost' => '{R}', 'type' => 'Sorcery', 'colors' => ['R'], 'text' => "Flame Jab deals 1 damage to any target.\nRetrace (You may cast this card from your graveyard by discarding a land card in addition to paying its other costs.)"], + 'Dregscape Zombie' => ['manaCost' => '{1}{B}', 'type' => 'Creature — Zombie', 'colors' => ['B'], 'power' => '2', 'toughness' => '1', 'text' => 'Unearth {B} ({B}: Return this card from your graveyard to the battlefield. It gains haste. Exile it at the beginning of the next end step or if it would leave the battlefield. Unearth only as a sorcery.)'], + 'Reassembling Skeleton' => ['manaCost' => '{1}{B}', 'type' => 'Creature — Skeleton Warrior', 'colors' => ['B'], 'power' => '1', 'toughness' => '1', 'text' => '{1}{B}: Return this card from your graveyard to the battlefield tapped.'], + 'Reverberate' => ['manaCost' => '{R}{R}', 'type' => 'Instant', 'colors' => ['R'], 'text' => 'Copy target instant or sorcery spell. You may choose new targets for the copy.'], + 'Artful Dodge' => ['manaCost' => '{U}', 'type' => 'Sorcery', 'colors' => ['U'], 'text' => "Target creature can't be blocked this turn.\nFlashback {U} (You may cast this card from your graveyard for its flashback cost. Then exile it.)"], + 'Momentary Blink' => ['manaCost' => '{1}{W}', 'type' => 'Instant', 'colors' => ['W'], 'text' => "Exile target creature you control, then return it to the battlefield under its owner's control.\nFlashback {3}{U} (You may cast this card from your graveyard for its flashback cost. Then exile it.)"], + 'Staggershock' => ['manaCost' => '{2}{R}', 'type' => 'Instant', 'colors' => ['R'], 'text' => "Staggershock deals 2 damage to any target.\nRebound (If you cast this spell from your hand, exile it as it resolves. At the beginning of your next upkeep, you may cast this card from exile without paying its mana cost.)"], + ]; + + private static function more(string $name): array + { + return ['uuid' => 'graveyard-cast-'.strtolower(preg_replace('/\W+/', '-', $name)), 'name' => $name, 'rarity' => 'common'] + self::MORE[$name]; + } + + private function put(int $seat, string $name, string $zone): int + { + return $this->game->addCard($seat, self::more($name), $zone)->id; + } + + private static function casualtySpell(): array + { + // Synthetic combination isolates casualty's pre-trigger copy from the cast's actual zone. + return ['uuid' => 'grave-casualty', 'name' => 'Grave Casualty', 'rarity' => 'common', 'manaCost' => '{1}{U}', 'type' => 'Instant', 'colors' => ['U'], 'text' => "Draw a card.\nFlashback {2}{U}\nCasualty 1"]; + } + + private function resolve(): void + { + $this->game->pass($this->game->priority); + $this->game->pass($this->game->priority); + } + + private function reload(): Game + { + return $this->game = Game::fromArray(json_decode(json_encode($this->game->toArray(), JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR)); + } + + private function assertRejectedWithoutMutation(callable $action): void + { + $before = $this->game->toArray(); + try { + $action(); + $this->fail('The action must be rejected.'); + } catch (GameException $exception) { + $this->assertNotSame('', $exception->getMessage()); + } + $this->assertSame($before, $this->game->toArray()); + } + + public function testRealCardWordingIsReadWhole(): void + { + foreach (array_keys(self::MORE) as $name) { + $this->assertSame([], (new CardDefinition(self::more($name)))->unsupported, $name); + } + foreach (['Secrets of the Dead', 'Burning Vengeance'] as $name) { + $this->assertSame('cast_from_graveyard', (new CardDefinition(self::more($name)))->triggered[0]['event']); + } + } + + public function testFlashbackTriggersAboveTheSpellButHandCastingDoesNot(): void + { + $game = $this->newGame(); + $this->put(0, 'Secrets of the Dead', GameObject::BATTLEFIELD); + $this->lands(0, 'Island', 5); + $think = $this->put(0, 'Think Twice', GameObject::HAND); + + $game->cast(0, $think); + $this->assertCount(1, $game->stack); + $this->resolve(); + $this->assertCount(1, $game->players[0]->hand); + $this->assertSame(GameObject::GRAVEYARD, $this->zone($think)); + + $game->cast(0, $think, 0, [], 'fb'); + $this->assertCount(2, $game->stack); + $this->assertSame($think, $game->stack[0]['object']); + $this->assertSame("Secrets of the Dead's ability", $game->stack[1]['label']); + $game = $this->reload(); + $this->resolve(); + $this->assertSame(GameObject::STACK, $this->zone($think), 'The trigger resolves before Think Twice.'); + $this->assertCount(2, $game->players[0]->hand); + $this->resolve(); + $this->assertCount(3, $game->players[0]->hand); + $this->assertSame(GameObject::EXILE, $this->zone($think)); + } + + public function testCombinedEntryAndGraveyardCastWordingRemainsUnsupported(): void + { + $card = new CardDefinition([ + 'uuid' => 'ominous-roost', 'name' => 'Ominous Roost', 'rarity' => 'uncommon', 'manaCost' => '{2}{U}', 'type' => 'Enchantment', 'colors' => ['U'], + 'text' => 'When this enchantment enters and whenever you cast a spell from your graveyard, create a 1/1 blue Bird creature token with flying and "This token can block only creatures with flying."', + ]); + $this->assertCount(1, $card->unsupported); + $this->assertStringContainsString('enters and whenever you cast a spell from your graveyard', $card->unsupported[0]); + $this->assertSame([], $card->triggered, 'Do not silently drop the entry event or part of the token ability.'); + } + + public function testDevotedGrafkeeperMillsThenTargetsOnlyAnOpponentsCreature(): void + { + $game = $this->newGame(); + $this->lands(0, 'Plains', 1); + $this->lands(0, 'Island', 1); + $mine = $this->battlefield(0, 'Grizzly Bears'); + $theirs = $this->battlefield(1, 'Grizzly Bears'); + $other = $this->battlefield(1, 'Hill Giant'); + $grafkeeper = $this->put(0, 'Devoted Grafkeeper // Departed Soulkeeper', GameObject::HAND); + $library = count($game->players[0]->library); + $game->cast(0, $grafkeeper); + $this->resolve(); + $this->resolve(); + $this->assertSame($library - 2, count($game->players[0]->library)); + $this->assertCount(2, $game->players[0]->graveyard); + + $this->lands(0, 'Island', 3); + $think = $this->put(0, 'Think Twice', GameObject::GRAVEYARD); + $game->cast(0, $think, 0, [], 'fb'); + $this->assertSame('trigger', $game->decision(0)); + $this->assertSame(['creature_opponent'], $game->triggerAwaitingTargets()['kinds']); + $this->assertRejectedWithoutMutation(fn () => $game->chooseTriggerTargets(0, ["o:{$mine}"])); + $this->assertRejectedWithoutMutation(fn () => $game->chooseTriggerTargets(0, ['p:1'])); + $game->chooseTriggerTargets(0, ["o:{$theirs}"]); + $this->resolve(); + $this->assertTrue($game->objects[$theirs]->tapped); + $this->assertFalse($game->objects[$other]->tapped); + $this->assertFalse($game->objects[$mine]->tapped); + $this->assertSame(GameObject::STACK, $this->zone($think)); + $this->resolve(); + $this->assertCount(1, $game->players[0]->hand); + } + + public function testDisturbedSoulkeeperDoesNotRetainTheFrontFaceTrigger(): void + { + $game = $this->newGame(); + $this->put(0, 'Secrets of the Dead', GameObject::BATTLEFIELD); + $this->lands(0, 'Plains', 1); + $this->lands(0, 'Island', 2); + $theirs = $this->battlefield(1, 'Grizzly Bears'); + $grafkeeper = $this->put(0, 'Devoted Grafkeeper // Departed Soulkeeper', GameObject::GRAVEYARD); + $game->cast(0, $grafkeeper, 0, [], 'db'); + $this->assertCount(2, $game->stack, 'Disturb is a graveyard cast for Secrets of the Dead.'); + $this->resolve(); + $library = count($game->players[0]->library); + $this->resolve(); + $this->assertSame('Departed Soulkeeper', $game->objects[$grafkeeper]->name()); + $this->assertTrue($game->objects[$grafkeeper]->transformed); + $this->assertSame($library, count($game->players[0]->library), 'The back face does not mill on entry.'); + $this->assertSame([], $game->stack); + $game = $this->reload(); + + $this->lands(0, 'Island', 3); + $think = $this->put(0, 'Think Twice', GameObject::GRAVEYARD); + $game->cast(0, $think, 0, [], 'fb'); + $this->assertSame('priority', $game->decision(0), 'The back face has no tap-target trigger.'); + $this->assertCount(2, $game->stack, 'Only Secrets of the Dead triggers.'); + $this->resolve(); + $this->resolve(); + $this->assertFalse($game->objects[$theirs]->tapped); + $this->assertCount(3, $game->players[0]->hand); + } + + public function testRetraceTriggersForEveryPaidCastWithoutFlashback(): void + { + $game = $this->newGame(); + $this->put(0, 'Secrets of the Dead', GameObject::BATTLEFIELD); + $this->lands(0, 'Mountain', 2); + $jab = $this->put(0, 'Flame Jab', GameObject::GRAVEYARD); + $land = $this->hand(0, 'Forest'); + + for ($cast = 1; $cast <= 2; $cast++) { + $game->cast(0, $jab, 0, ['p:1'], 'rt'); + $this->assertCount(2, $game->stack); + $this->assertFalse($game->stack[0]['flashback'] ?? false); + $this->assertSame(GameObject::GRAVEYARD, $this->zone($land), 'Retrace discards a land as a cost.'); + $this->assertCount(0, $game->players[0]->hand); + $this->resolve(); + $this->assertCount(1, $game->players[0]->hand); + $land = $game->players[0]->hand[0]; + $this->resolve(); + $this->assertSame(20 - $cast, $this->life(1)); + $this->assertSame(GameObject::GRAVEYARD, $this->zone($jab)); + } + } + + public function testAnOpponentsGraveyardCastDoesNotTriggerYourPermanent(): void + { + $game = $this->newGame(); + $this->put(0, 'Secrets of the Dead', GameObject::BATTLEFIELD); + $this->lands(1, 'Island', 3); + $think = $this->put(1, 'Think Twice', GameObject::GRAVEYARD); + $game->pass(0); + $game->cast(1, $think, 0, [], 'fb'); + $this->assertCount(1, $game->stack); + $this->resolve(); + $this->assertCount(0, $game->players[0]->hand); + $this->assertCount(1, $game->players[1]->hand); + } + + public function testGraveyardAbilitiesDoNotCountAsCastingSpells(): void + { + $game = $this->newGame(); + $this->put(0, 'Secrets of the Dead', GameObject::BATTLEFIELD); + $this->lands(0, 'Swamp', 3); + $zombie = $this->put(0, 'Dregscape Zombie', GameObject::GRAVEYARD); + $skeleton = $this->put(0, 'Reassembling Skeleton', GameObject::GRAVEYARD); + + $game->unearth(0, $zombie); + $this->assertCount(1, $game->stack); + $this->resolve(); + $this->assertSame(GameObject::BATTLEFIELD, $this->zone($zombie)); + $this->assertCount(0, $game->players[0]->hand); + + $game->regrow(0, $skeleton); + $this->assertCount(1, $game->stack); + $this->resolve(); + $this->assertSame(GameObject::BATTLEFIELD, $this->zone($skeleton)); + $this->assertTrue($game->objects[$skeleton]->tapped); + $this->assertCount(0, $game->players[0]->hand); + } + + public function testFailedCastsDoNotCreateTriggersOrMutateTheGame(): void + { + $game = $this->newGame(); + $this->put(0, 'Secrets of the Dead', GameObject::BATTLEFIELD); + $think = $this->put(0, 'Think Twice', GameObject::GRAVEYARD); + $this->assertRejectedWithoutMutation(fn () => $game->cast(0, $think, 0, [], 'fb')); + + $lands = $this->lands(0, 'Island', 3); + $hand = $this->put(0, 'Think Twice', GameObject::HAND); + $this->assertRejectedWithoutMutation(fn () => $game->cast(0, $hand, 0, [], 'fb')); + $dodge = $this->put(0, 'Artful Dodge', GameObject::GRAVEYARD); + $this->assertRejectedWithoutMutation(fn () => $game->cast(0, $dodge, 0, ["o:{$lands[0]}"], 'fb')); + $this->assertSame([], $game->stack); + } + + public function testTheTriggerStillResolvesWhenTheGraveyardSpellIsCountered(): void + { + $game = $this->newGame(); + $this->put(0, 'Secrets of the Dead', GameObject::BATTLEFIELD); + $this->lands(0, 'Island', 3); + $this->lands(1, 'Island', 2); + $think = $this->put(0, 'Think Twice', GameObject::GRAVEYARD); + $counter = $this->hand(1, 'Counterspell'); + $game->cast(0, $think, 0, [], 'fb'); + $spell = $game->stack[0]['id']; + $game->pass(0); + $game->cast(1, $counter, 0, ["s:{$spell}"]); + $this->resolve(); + $this->assertSame(GameObject::EXILE, $this->zone($think)); + $this->assertCount(1, $game->stack); + $game = $this->reload(); + $this->resolve(); + $this->assertCount(1, $game->players[0]->hand, 'Only Secrets of the Dead draws a card.'); + $this->assertSame([], $game->stack); + } + + public function testRemovingTheTriggerSourceDoesNotRemoveThePendingTrigger(): void + { + $game = $this->newGame(); + $secrets = $this->put(0, 'Secrets of the Dead', GameObject::BATTLEFIELD); + $this->lands(0, 'Island', 3); + $this->lands(1, 'Plains', 2); + $think = $this->put(0, 'Think Twice', GameObject::GRAVEYARD); + $disenchant = $game->addCard(1, ['uuid' => 'graveyard-cast-disenchant', 'name' => 'Disenchant', 'rarity' => 'common', 'manaCost' => '{1}{W}', 'type' => 'Instant', 'colors' => ['W'], 'text' => 'Destroy target artifact or enchantment.'], GameObject::HAND)->id; + $game->cast(0, $think, 0, [], 'fb'); + $game->pass(0); + $game->cast(1, $disenchant, 0, ["o:{$secrets}"]); + $this->resolve(); + $this->assertSame(GameObject::GRAVEYARD, $this->zone($secrets)); + $game = $this->reload(); + $this->resolve(); + $this->assertCount(1, $game->players[0]->hand, 'The already-triggered ability survives its source.'); + $this->assertSame(GameObject::STACK, $this->zone($think)); + $this->resolve(); + $this->assertCount(2, $game->players[0]->hand); + } + + public function testDamageTargetSelectionAndResolutionSurviveSaveAndResume(): void + { + $game = $this->newGame(); + $this->put(0, 'Burning Vengeance', GameObject::BATTLEFIELD); + $lands = $this->lands(0, 'Island', 3); + $giant = $this->battlefield(1, 'Hill Giant'); + $think = $this->put(0, 'Think Twice', GameObject::GRAVEYARD); + $game->cast(0, $think, 0, [], 'fb'); + $this->assertSame('trigger', $game->decision(0)); + $game = $this->reload(); + $this->assertSame('trigger', $game->decision(0)); + $this->assertRejectedWithoutMutation(fn () => $game->chooseTriggerTargets(1, ["o:{$giant}"])); + $this->assertRejectedWithoutMutation(fn () => $game->chooseTriggerTargets(0, ["o:{$lands[0]}"])); + $game->chooseTriggerTargets(0, ["o:{$giant}"]); + $game = $this->reload(); + $this->resolve(); + $this->assertSame(2, $game->objects[$giant]->damage); + $this->assertSame(GameObject::STACK, $this->zone($think)); + $this->assertCount(0, $game->players[0]->hand); + $this->resolve(); + $this->assertCount(1, $game->players[0]->hand); + } + + public function testDamageDoesNotFollowATargetIntoANewIncarnation(): void + { + $game = $this->newGame(); + $this->put(0, 'Burning Vengeance', GameObject::BATTLEFIELD); + $this->lands(0, 'Island', 3); + $this->lands(1, 'Plains', 2); + $bears = $this->battlefield(1, 'Grizzly Bears'); + $incarnation = $game->objects[$bears]->incarnation; + $think = $this->put(0, 'Think Twice', GameObject::GRAVEYARD); + $blink = $this->put(1, 'Momentary Blink', GameObject::HAND); + $game->cast(0, $think, 0, [], 'fb'); + $game->chooseTriggerTargets(0, ["o:{$bears}"]); + $game->pass(0); + $game->cast(1, $blink, 0, ["o:{$bears}"]); + $this->resolve(); + $this->assertGreaterThan($incarnation, $game->objects[$bears]->incarnation); + $game = $this->reload(); + $this->resolve(); + $this->assertSame(GameObject::BATTLEFIELD, $this->zone($bears)); + $this->assertSame(0, $game->objects[$bears]->damage); + $this->assertSame(GameObject::STACK, $this->zone($think)); + $this->resolve(); + $this->assertCount(1, $game->players[0]->hand); + } + + public function testCopyingAGraveyardSpellDoesNotTriggerAgain(): void + { + $game = $this->newGame(); + $this->put(0, 'Secrets of the Dead', GameObject::BATTLEFIELD); + $this->lands(0, 'Island', 3); + $this->lands(0, 'Mountain', 2); + $think = $this->put(0, 'Think Twice', GameObject::GRAVEYARD); + $copy = $this->put(0, 'Reverberate', GameObject::HAND); + $game->cast(0, $think, 0, [], 'fb'); + $game->cast(0, $copy, 0, ["s:{$game->stack[0]['id']}"]); + $this->resolve(); + $this->assertCount(3, $game->stack, 'The original, its copy and one graveyard-cast trigger.'); + $this->resolve(); + $this->resolve(); + $this->resolve(); + $this->assertCount(3, $game->players[0]->hand); + $this->assertSame([], $game->stack); + } + + public function testACopyAlreadyOnTheStackDoesNotHideTheOriginalCastOrigin(): void + { + $game = $this->newGame(); + $this->put(0, 'Secrets of the Dead', GameObject::BATTLEFIELD); + $this->lands(0, 'Island', 3); + $fodder = $this->battlefield(0, 'Grizzly Bears'); + $card = self::casualtySpell(); + $this->assertSame([], (new CardDefinition($card))->unsupported); + $spell = $game->addCard(0, $card, GameObject::GRAVEYARD)->id; + $game->cast(0, $spell, 0, [], 'fb,casualty'); + $this->assertSame(GameObject::GRAVEYARD, $this->zone($fodder)); + $this->assertCount(3, $game->stack); + $this->assertSame("Secrets of the Dead's ability", $game->stack[2]['label']); + $this->resolve(); + $this->resolve(); + $this->resolve(); + $this->assertCount(3, $game->players[0]->hand); + $this->assertSame(GameObject::EXILE, $this->zone($spell)); + } + + public function testAPermanentSacrificedToCastTheSpellIsNotThereToTrigger(): void + { + $game = $this->newGame(); + $this->lands(0, 'Island', 3); + $grafkeeper = $this->put(0, 'Devoted Grafkeeper // Departed Soulkeeper', GameObject::BATTLEFIELD); + $theirs = $this->battlefield(1, 'Grizzly Bears'); + $spell = $game->addCard(0, self::casualtySpell(), GameObject::GRAVEYARD)->id; + $game->cast(0, $spell, 0, [], 'fb,casualty'); + $this->assertSame(GameObject::GRAVEYARD, $this->zone($grafkeeper)); + $this->assertSame('priority', $game->decision(0)); + $this->assertCount(2, $game->stack, 'Only the spell and casualty copy, not the sacrificed Grafkeeper.'); + $this->resolve(); + $this->resolve(); + $this->assertFalse($game->objects[$theirs]->tapped); + $this->assertCount(2, $game->players[0]->hand); + $this->assertSame([], $game->stack); + } + + public function testReboundFromExileDoesNotCountAsAGraveyardCast(): void + { + $game = $this->newGame(); + $this->put(0, 'Secrets of the Dead', GameObject::BATTLEFIELD); + $this->lands(0, 'Mountain', 3); + $shock = $this->put(0, 'Staggershock', GameObject::HAND); + $game->cast(0, $shock, 0, ['p:1']); + $this->resolve(); + $this->assertSame(GameObject::EXILE, $this->zone($shock)); + $this->passUntil(Step::Upkeep, 3); + $hand = count($game->players[0]->hand); + $game->cast(0, $shock, 0, ['p:1'], 'rb'); + $this->assertCount(1, $game->stack); + $this->resolve(); + $this->assertSame(16, $this->life(1)); + $this->assertSame(GameObject::GRAVEYARD, $this->zone($shock)); + $this->assertCount($hand, $game->players[0]->hand); + } +}