Skip to content

engine: decompose the one-card bundled effect verbs #828

Description

@ninthworld

Goal

The one-card effect verbs are split into the reusable halves they are made of, so the next set
can reach for "exile the top N", "you may cast a card exiled this way", or "each player chooses
one of theirs" without a new enum variant and six new match arms.

Context and evidence

Parent: #819, §2.3.

Across the 299 catalog files there are 121 distinct kind values, 60 of which are used by
exactly one card.
Some are genuinely singular primitives and are fine — win_the_game,
no_maximum_hand_size, play_lands_from_graveyard are one card's rule and nothing else's.

The problem is the ones that bundle several rules steps into one opaque verb instead of
composing verbs that already exist:

Verb Card What it bundles
sacrifice_chosen_per_player Vaevictis Asmadi per-seat targeting, a sacrifice, a reveal, and a conditional put-onto-battlefield — behind one reveal_top: bool
may_cast_exiled_this_way Chaos Wand a may-cast and, silently, "put the rest on the bottom in a random order"
exile_from_library_until Chaos Wand the dig half of the same sentence
reveal_top_and_may_play Djinn of Wishes reveal + may-play-for-free + exile-if-declined
remove_all_counters Suncleanser removal + a continuous prohibition (then_forbid)
player_loses_all_counters Suncleanser the same, for a player — a second verb for one card's second mode
exile_library_except_bottom Nicol Bolas
shuffle_self_into_library Inferno Hellion
exchange_control Switcheroo
ignore_hexproof Detection Tower
put_hand_onto_battlefield_face_down Tezzeret, Cruel Machinist
exile_self_and_return_transformed Nicol Bolas

sacrifice_chosen_per_player also owns the only special case in Effect::target_groups
(ability/effect/targets.rs:99) — the one effect whose slot count comes from the seat count
rather than from the card. That per-seat targeting is the genuinely reusable half, and nothing
else can reach it.

exile_top_for_play is the counterexample and the model: properly decomposed, used by two
cards (Apex of Power, Dark-Dweller Oracle), with cast_only as a field rather than a twin verb.

The engine guide states the tax plainly — "each new primitive is one enum variant plus every
exhaustive match that consumes it, across the resolver and the server's rules-text formatter —
both are wildcard-free, so the compiler names every site." Effect is at 82 variants
against 86 Effect:: arms in sage-server/src/rules_text/effects.rs alone. A second set
will not cost half as much per card; it will cost more, because the phrasings it needs are the
ones that were never worth a variant the first time.

exclusions.json names at least three limits that are limits of this bundling rather than of
the rules — "Shuffling anything but the effect's own source into a library", "Gaining
control of a permanent for longer than a turn"
, and "Playing a card from a zone other than
the hand, the command zone, or a permitted graveyard"
— each of which is a bundled verb's
missing half.

Scope

Split each bundled verb along its own seams, keeping behaviour identical. At minimum:

  • Per-seat targeting lifted out of sacrifice_chosen_per_player into a target-group shape
    any effect can declare, so the Vaevictis trigger becomes a sacrifice plus a reveal-and-put
    rather than one verb.
  • Chaos Wand's sentence as three composable effects — dig until a class, may-cast a card
    exiled this way, bottom the rest in a random order — with the bottoming visible in the
    authored JSON rather than hidden inside the cast verb.
  • Djinn of Wishes as reveal + may-play + otherwise-exile, reusing whatever
    exile_top_for_play and may_cast_exiled_this_way become.
  • Suncleanser's two modes sharing one counter-removal verb and one prohibition, with the
    subject (permanent or player) as a field.
  • The remaining singles reviewed one at a time: each either keeps its variant with the reason
    stated where it lives, or gains a field that makes it reusable. shuffle_self_into_library
    and exchange_control in particular are one destination and one duration away from the
    general cases exclusions.json currently names.

Behaviour-preserving throughout. No card's rules change.

Acceptance criteria

  • Vaevictis Asmadi's trigger is composed of effects that are individually reusable, and the
    per-seat target group is declarable by any effect.
  • Effect::target_groups has no effect-specific special case; the seat-count arity is a
    property of a target group, not of one verb.
  • Chaos Wand's "put the rest on the bottom in a random order" is visible in its authored
    JSON.
  • Djinn of Wishes and Chaos Wand share their may-cast machinery.
  • Suncleanser's two modes share one removal verb and one prohibition.
  • Every remaining single-card kind either has a stated reason for being singular, next to
    its definition, or has been given the field that makes it general.
  • The Effect variant count is lower than it is today, and the reduction is stated in
    the PR.
  • At least one exclusions.json entry naming a bundled verb's missing half is dropped or
    narrowed, with a card or a test proving the capability.
  • make compat regenerated; supported and excluded stay disjoint.
  • make verify green.

Test and validation evidence

  • The existing behavioural tests for each affected card are the net; they should pass unchanged
    in intent over re-authored JSON.
  • A test proving one newly-general capability — e.g. a second card, or a scenario file, that
    reaches a decomposed half the original card did not need.
  • Rules-text tests: a decomposed sentence must still render as one readable sentence, not as
    three clauses in a list. This is the visible risk and wants a snapshot.
  • make verify.

Dependencies and sequencing

Independent of #824#827 in subject matter, but overlapping in files — sequence rather than
parallelise. Best done after them, since the collapsed selector and payload shapes are what
several of these verbs would decompose into. Must land before another set is authored, which
is the whole point.

Estimated scope

Large — broad change requiring a split assessment. It splits cleanly per verb if it needs to:
sacrifice_chosen_per_player alone is a coherent PR, and so is the Chaos-Wand/Djinn pair. Take
it verb by verb with the suite green at each step rather than as one landing.

Risks and constraints

  • Rules text is the visible risk. A bundled verb renders one sentence; three composed
    effects render three. The server's formatter must recompose them, or players read a card
    differently than it is printed — and AGENTS.md makes playing the merge criterion.
  • Decomposition can change when things happen within a resolution. Chaos Wand's bottoming
    happens after the may-cast; making it a separate effect makes that ordering authored rather
    than implied, which is an improvement only if the authored order is right.
  • Some of these genuinely are one card's rule. Splitting a verb that nothing will ever reuse
    trades one variant for three and is a net loss; the "stated reason for being singular"
    criterion exists so that judgment is recorded rather than re-litigated.

Non-goals

Not the selector unification (#824, #825) or the verb matrix (#826, #827). Not adding
mechanics no bundled verb already contains. Not authoring a second set.

Documentation impact

docs/card-schema.md — "How far the vocabulary reaches" enumerates these verbs and is rewritten
around the decomposed ones. exclusions.json — entries dropped or narrowed as capabilities land.
crates/sage-engine/AGENTS.md — "The IR is the constraint" section may want a sentence on
preferring composition to a new verb, which is the lesson this issue encodes.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions