Skip to content

feat(engine): the self row of a keyword grant, and one target for one sentence - #831

Merged
ninthworld merged 2 commits into
mainfrom
engine/self-grant-and-one-target
Aug 8, 2026
Merged

ninthworld merged 2 commits into
mainfrom
engine/self-grant-and-one-target

Conversation

@ninthworld

Copy link
Copy Markdown
Owner

Closes #821. Part of #819. Independent of #829/#830 — branches off main.

The two M19 cards the IR could not say. Both are the same hole seen from two sides: one printed sentence has to be one effect.

Resplendent Angel — an ability missing from the catalog entirely

Printed: {3}{W}{W}{W}: Until end of turn, this creature gets +2/+2 and gains lifelink. There was no such ability in the definition.

The subject × payload matrix has a hole on the self row:

power/toughness keyword restriction
source PumpSelf RestrictSelf
target Pump Pump.keywords / GrantKeyword Pump.restrictions / Restrict
class PumpAll GrantKeywordAll RestrictAll

The only self-directed keyword addition was AlterAbilitiesSelf.gain, whose sibling field is lose_all — reaching for the lose-all-abilities verb to say "gains lifelink". PumpSelf carries keywords now, so the pump and the grant share one subject, one duration, and one CR 613.7 timestamp. Tests assert the printed flying survives it: this grants, it does not replace.

Skyrider Patrol — one printed target, two slots

Printed: put a +1/+1 counter on another target creature you control, **and that creature** gains flying until end of turn.

Authored as put_counters beside a standalone grant_keyword, both naming another_creature_you_control. Effect::target_groups is a flat_map over the effect list with no dedup, so the ability advertised two independent slots — counter one creature, fly a different one.

PutCounters carries keywords now, for the reason Pump already did. The counter stays on the permanent and the keyword is gone at cleanup: one sentence, two durations, one target.

The existing test was filling both slots with the same creature, which is how it stayed green while the card was wrong. It now places a second eligible creature, asserts a two-target answer is refused, and asserts the bystander took no counter and no flying.

Rules text

Both new shapes render, and the counter case points back with a pronoun rather than repeating the class — …on another target creature you control, and that creature gains flying until end of turn. Writing the noun twice is what reads as a second target.

One acceptance criterion moved, and why

#821 asked for the double-slot shape to be refused "by the type or by a validator with a named Violation, not by review."

I implemented that — Violation::TwoTargetsOfOneClass — and it failed the build on crates/sage-server/src/view/stack.rs's test_twin_bolt fixture: a two-target damage spell that exists specifically to prove the projection pairs two same-class slots positionally, in order. That shape is supported and correct.

So the rule is wrong as a build-time check. Whether a card names one target or two is knowable only from the printed card, and two same-class slots are legitimately representable. I backed the validator out rather than ship one that refuses a legal shape, and the guarantee is a behavioural claim instead: the Skyrider test above fails if the slot count regresses.

This becomes structural in #827, where Subject is a single field on one effect and the two-slot shape stops being representable for a card that declares one. I've noted this on #821.

Docs

docs/card-schema.md — the "Granting keywords" section gains the self row and the counter's target, beside the target row it already had, with the alter_abilities_self distinction stated.

make verify green — e2e-smoke and cargo deny included. make compat shows no drift.

🤖 Generated with Claude Code

… sentence

Two M19 cards the ability IR could not say (#821).

Resplendent Angel's `{3}{W}{W}{W}: Until end of turn, this creature gets +2/+2
and gains lifelink` was missing from the catalog entirely, and the shape of the
vocabulary is why. The subject × payload matrix has a hole on the self row: the
only self-directed keyword *addition* was `alter_abilities_self`'s `gain`, whose
sibling field is `lose_all`. Reaching for the lose-all-abilities verb to say
"gains lifelink" reads as a card doing something it does not do, so `pump_self`
carries its own keywords now — one printed sentence, one effect, one CR 613.7
timestamp, and the printed flying untouched.

Skyrider Patrol prints one target and asked for two. `put a +1/+1 counter on
another target creature you control, **and that creature** gains flying` was
authored as a counter beside a standalone grant, and `Effect::target_groups` is
a `flat_map` with no dedup — so the ability advertised two slots and a player
could counter one creature while a different one flew. `put_counters` carries
`keywords` now, for the reason `pump` already did. The counter stays and the
keyword is gone at cleanup: one sentence, two durations, one target.

The test that covered the Patrol had been filling both slots with the same
creature, which is how it stayed green while the card was wrong. It now places a
second eligible creature, asserts a two-target answer is refused, and asserts
the bystander took nothing.

A validator refusing two sibling effects of one class was written and then
backed out: two same-class slots are a shape the engine handles correctly and
`view::stack`'s two-target projection test depends on. Whether a card names one
target or two is knowable only from the printed card, so it stays a behavioural
claim rather than a build-time one. Making it structural is #827's job, where
the subject becomes one field.

Closes #821. Part of #819.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ninthworld
ninthworld merged commit c7e1951 into main Aug 8, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

engine: two M19 cards the ability IR cannot currently say

1 participant