Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/card-coverage-progress.md
Original file line number Diff line number Diff line change
@@ -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: <https://mtgjson.com/api/v5/AllPrintings.sqlite.zip>, 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.
4 changes: 4 additions & 0 deletions docs/rules-engine-continuation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 7 additions & 4 deletions src/MTGPocket/Game/Game.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/MTGPocket/Game/TextParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand Down
35 changes: 35 additions & 0 deletions tests/Game/AttackingCounterTextTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace MTGPocket\Tests\Game;

use MTGPocket\Game\CardDefinition;
use MTGPocket\Game\Game;
use MTGPocket\Game\GameObject;
use MTGPocket\Game\Step;

/**
* @covers \MTGPocket\Game\TextParser
* @covers \MTGPocket\Game\Game
*/
final class AttackingCounterTextTest extends GameTestCase
{
public function testCountersOnEachAttackerIncludeTheSource(): void
{
$game = $this->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]));
}
}
Loading
Loading