Skip to content

ship/ai suggestions batch - #1661

Merged
matthewevans merged 10 commits into
mainfrom
ship/ai-suggestions-batch
May 31, 2026
Merged

ship/ai suggestions batch#1661
matthewevans merged 10 commits into
mainfrom
ship/ai-suggestions-batch

Conversation

@matthewevans

Copy link
Copy Markdown
Member
  • fix(ai): combat correctness — attack planeswalkers, avoid commander trades, gate lifelink
  • fix(ai): penalize self-protection ACTIVATIONS with no threat (not just spells)
  • feat(ai): PlaneswalkerLoyaltyPolicy — use the planeswalker, don't sac it for a trick
  • feat(ai): EquipmentPriorityPolicy — stop the every-turn equip shuffle
  • feat(ai): LandSequencingPolicy — play a normal land before a Karoo bounce-land
  • fix(engine): smarter auto-tap source order — colorless-for-generic, spare creatures
  • feat(ai): don't pay for activations whose payoff is gated off (ConditionGatedActivation)
  • fix(engine): make Omo's everything counter grant all land/creature types
  • feat(ai): draft bots value and play nonbasic fixing lands

…rades, gate lifelink

Three attack-declaration correctness fixes in combat_ai (reported in the
#ai-suggestions channel). Attack declaration is decided directly by combat_ai,
not the policy registry, so these are surgical heuristics there.

- #8 lifelink: gate the lifelink attack bonus on
  free_damage || favorable_trade || attacker_survives, so it can no longer
  justify a pure-loss chump block (CR 702.15b: life is still gained on a
  simultaneous trade, so even/surviving swings are unaffected — but throwing the
  creature away for nothing is not worth the life).
- #6 commander: don't trade the commander into a block it does not survive
  unless pushing lethal (CR 903.8 commander tax). Also excluded from the
  desperation alpha-strike fallback, computed before its cost/benefit math so a
  wide board cannot leak it back in.
- #5 planeswalkers: consume the engine's pre-computed valid_attack_targets to
  redirect the fewest large attackers (largest-power-first) needed to KILL the
  highest-loyalty opponent planeswalker, single-opponent path only; never
  dilutes a lethal/near-lethal swing and never empties the face.
  Multiplayer pods and Battle targets deferred.

Plan reviewed to convergence via /review-plan (3 rounds, perf-verified: no
per-node GameState clone, projection, or O(N^2) battlefield sweep). New tests
drive the chooser through the real engine target list and are mutation-checked
(reverting either subtle gate fails exactly its discriminating test). clippy +
test-ai green.
…t spells)

Extend ReactiveSelfProtectionPolicy to fire on ActivateAbility, not only
CastSpell. The AI repeatedly paid "discard a card: ~ gains protection from
everything" until hand-empty, and activated Sylvan Safekeeper ("sacrifice a
land: target creature you control gains shroud") on turn 1 for nothing — the
same class as casting Teferi's Protection into an empty board, which the policy
already handled for spells.

- decision_kinds() now includes ActivateAbility; the verdict guard accepts both
  CastSpell and ActivateAbility.
- Classifier extension (required): a GenericEffect static scoped to ParentTarget
  grants to whatever the parent ability targets, so self-scoping is decided by
  the enclosing GenericEffect.target — this covers the "target creature you
  control gains <defensive keyword>" class (Sylvan Safekeeper). The discard-cost
  "~ gains X" class already lowered to SelfRef and was caught.
- Reuses is_self_protection_effect + any_immediate_threat unchanged; the
  expensive threat check stays gated behind the cheap classifier, so there is no
  per-candidate cost for non-protection activations.

Plan reviewed to convergence via /review-plan (caught that Sylvan Safekeeper's
grant is ParentTarget-scoped, which the classifier would have silently missed).
Two discriminating tests (SelfRef + ParentTarget shapes) drive the verdict with
a real ActivateAbility candidate and are mutation-checked. clippy + test-ai green.
… it for a trick

New tactical policy on ActivateAbility for planeswalker loyalty abilities
(reported in #ai-suggestions): the AI didn't use a loyalty ability the turn a
walker landed (#4d), and fired value-negative minuses that sacrificed the
walker for a single-target combat trick instead of the value plus (#4e,
Quintorius "-4: target creature gains double strike").

Blunder-only design: the ONLY penalized case is sacrificing/crippling the
planeswalker (left at <=1 loyalty) for a single-target combat trick; every
other loyalty activation — plus abilities, removal, emblems/ultimates, tokens,
draw, mass effects, steal, reanimation — earns a modest "use your planeswalker"
bonus. This avoids classifying effect value (which would misclassify the 76
CreateEmblem ultimates as low-value and suppress them); by penalizing only the
combat-trick-sacrifice shape, ultimates and removal are structurally never
penalized. #4d falls out because using a walker beats passing; #4e is the
penalty branch.

is_combat_trick = Effect::Pump gated on effect_polarity == Beneficial (excludes
negative-pump removal like Davriel's -3/-3), OR a GenericEffect granting only
beneficial keyword/+P/+T mods to the single targeted creature (affected ==
ParentTarget, distinguishing it from a Typed{You} team anthem). Reuses
effect_polarity (FreeOutletActivation precedent). CR 606.1.

Plan reviewed to convergence via /review-plan (3 rounds — caught that
effect_profile misses 76 emblem-ultimates, and that an unconditional Pump arm
would penalize negative-pump removal). 9 tests drive the verdict with a real
ActivateAbility candidate, incl. BLOCKER regression guards for emblem-ultimate
and negative-pump removal; the negative-pump guard is mutation-checked. clippy
+ test-ai green.
New tactical policy on ActivateAbility (reported in #ai-suggestions): the AI
spent all its remaining mana every turn moving an Equipment between creatures
for no board improvement. The AI reaches equip via ActivateAbility on the
equip ability (effect Effect::Attach), where mana is committed and PassPriority
is a sibling candidate — so the policy fires there and penalizes the activation
when the equipment is already on the best body, making the AI keep its mana.

- Unattached equip and genuine upgrades are neutral (0.0); the policy never
  rewards equipping (the problem is over-equipping). Only a re-equip with no
  bigger body to move to is penalized.
- "Bigger body" compares base_power, NOT live power: the host's live power
  already includes this equipment's own +P buff (folded in via attached_to by
  the layer system), so comparing live power would make the equipped host
  out-power every bare upgrade target and wrongly penalize legitimate moves.
  base_power is the printed-baseline proxy, consistent on both sides.

Guard: source has subtype "Equipment" + ability effect is Effect::Attach
(CR 301.5: Equipment hosts are creatures). Performance: gated behind that cheap
guard, then one own-creature base-power scan; no clone/projection.

Plan reviewed to convergence via /review-plan (caught that live `power` includes
the equipment's own buff, which would suppress every upgrade). The
equip_upgrade_allowed test is built on a buffed host (live 4, base 2) with a
base-3 target, so it fails under live-power and passes only under base_power —
self-discriminating. clippy + test-ai green.
…unce-land

New tactical policy on PlayLand (reported in #ai-suggestions "AI Cast & Bounce
lands"): the AI played a Karoo bounce-land (Simic Growth Chamber) first when it
should play a different land first, losing tempo. PlayLand was declared in
BoardDevelopmentPolicy.decision_kinds() but never scored, so land choice was
softmax noise; this fills the gap for the bounce-land case.

When the land being played is a self-bouncing land AND a non-bouncing land is
also in hand, deprioritize the bounce-land (-1.5) so the non-bounce land wins
the argmax and is played first. When the bounce-land is the only land in hand,
no penalty — it plays normally. The deferral is recoverable (within-turn
reorder only).

Detection is structural (no card names): an ETB trigger (ChangesZone,
destination Battlefield, valid_card SelfRef) whose execute chain bounces a
Land you control (controller == You). Reuses ability_chain::collect_chain_effects.
This matches the whole Ravnica/MOM Effect::Bounce bounce-land cycle; the
Mercadian Karoo/Coral Atoll sacrifice-self cycle (Effect::Sacrifice) has no
sequencing downside and is deliberately not matched.

Deferred (#4b, separate SelectTarget decision, documented): when the bounce
ETB returns "a land you control," the AI should pick the least-useful land, not
the just-played bounce-land — needs its own target-selection policy.

Plan reviewed to convergence via /review-plan (confirmed trigger_definitions are
populated on hand objects, so detection fires at the PlayLand step; corrected a
false coverage claim and tightened the target to controller==You). Tests drive
a real PlayLand candidate; the SGC-detected penalty test plus the non-bounce-ETB
na test together discriminate the structural matcher. clippy + test-ai green.
…pare creatures

The auto-tap planner's card_tier sort axis ignored two cheap, in-payment
signals, so it would drain a dual land's colored capacity to pay a generic
cost (when a painland's free {C} row was available) and tap a creature mana
dork when a non-creature rock could pay the same shard.

Refine the card_tier middle key of auto_tap_mana_sources_inner's sort closure
into a 6-tier ladder (penalty axis unchanged):
  0 free-colorless land row (ideal generic filler — commits no colored
    production a later shard in this same payment needs)
  1 other land row
  2 non-land non-creature mana source (rock / Signet)
  3 non-land creature mana dork    — CR 509.1a: keep would-be blockers untapped
  4 land-creature (animated manland) — CR 509.1a
  5 deprioritized own-source

The colorless-row signal is read per-row off the existing ManaSourceOption
fields (atomic_combination + mana_type), not the per-object flexibility flag
(which is stamped identically on every row and can't tell a painland's {C}
row from its colored rows). O(1) per source, no hand lookahead — safe in the
AI cost-projection hot loop.

Two discriminating tests (mutation-checked: both fail on the old 4-tier
ladder): auto_tap_uses_colorless_row_for_generic_preserving_dual ({1}{G}) and
auto_tap_prefers_rock_over_creature_dork ({G}).

Reported via Discord #ai-suggestions ("Auto Cast Mana Selections").
…ionGatedActivation)

The AI activated hideaway payoff abilities (e.g. Mosswort Bridge's "{G},{T}:
play the exiled card if your creatures' total power is 10 or greater") every
turn even far under the threshold, burning mana for an effect that does
nothing. The engine is rules-correct here — per CR 602.5 / the Shelldock Isle
ruling the ability is legal to activate regardless; only the CastFromZone
effect is gated at resolution (CR 608.2c) — so this is purely an AI-value miss.

New ConditionGatedActivationPolicy (DecisionKind::ActivateAbility): when an
activated ability with a cost has a top-level intervening-if condition that is
currently false, apply a soft penalty so PassPriority (or a better line) wins.
Generalizes to the whole "Cost: do X if [board condition]" class — every
hideaway land and beyond, not one card. Soft (not Reject) because an ability's
cost can change the very thing its condition checks (sacrifice-then-"if you
control no creatures"); the condition is read pre-cost.

Condition evaluation is delegated to a new public engine helper
`ability_condition_currently_met`, which wraps `effects::evaluate_condition`
behind a conservative allowlist: it judges only board/controller-relative
QuantityCheck conditions and returns None for anything needing resolution-time
context (chosen targets, cast/trigger event), so the policy never guesses.

Tests: engine helper unit test (board-relative Some(false)/Some(true),
no-condition None, target-relative None) + 5 policy tests. Discriminating via
unique reason-kind sentinels.

Reported via Discord #ai-suggestions ("Mosswort Bridge").
Omo, Queen of Vesuva placed an "everything counter" that did nothing: the two
type-granting static abilities silently mis-parsed, and the trigger only
targeted a land (the "and up to one target creature" half became Unimplemented).

Three fixes:
- Trigger: parse "put a counter on each of up to one target A and up to one
  target B" as two PutCounter siblings, each with its own optional target.
  Also recover the PRIMARY clause's "up to one" cardinality, which
  try_parse_verb_and_target dropped when lowering to ZoneCounterProxy — a
  latent bug across the whole compound-PutCounter class, not just Omo.
- Land static: "is every land type" now lowers to a new
  ContinuousModification::AddAllLandTypes (all 17 land subtypes per CR 205.3i),
  not the no-op AddType{Land} it produced before. The new Layer-4 marker
  iterates the canonical card_type::LAND_SUBTYPES; basic types among them grant
  mana abilities automatically via the existing intrinsic-mana pass (CR 305.7).
  Distinct from AddAllBasicLandTypes (CR 305.6, 5 basic types).
- Creature static: "each nonland creature with an everything counter on it is
  every creature type" now parses (counter-conditioned subject built from the
  existing TypeFilter::Non(Land) + parse_counter_suffix) and maps to the
  existing AddAllCreatureTypes.

Tests: parser units (both statics + the two-target trigger), a layer test
(counter -> all types; mutation-checked that no-counter objects gain nothing),
and a full-pipeline integration test (Omo enters -> resolve trigger -> select
land + creature -> assert all land/creature types via the layer system).

Reported via Discord #ai-suggestions ("AI Cast & Bounce lands" / Omo).
Draftbots never picked or played nonbasic fixing lands: evaluate_draft_card
zeroed every non-creature land, and the deck-builder filled the manabase with
basics only, ignoring drafted duals.

- Pick value: a nonbasic land that taps for 2+ colors now scores `fixing_land`
  (~card-draw value — above filler, below playables/removal), so bots take
  duals mid-pack instead of wheeling them. Mono/colorless lands stay at 0.
- Deck build: suggest_deck now admits on-color drafted fixing lands into the
  manabase, each replacing one basic so the 40-card total never drifts. Gated
  to the standard-basics fill policy (a custom addable policy supplies its own
  lands) and to drafted copies the player actually owns.

New shared `mana_colors::land_produced_color_types` (parts-based core over
subtypes + abilities) is the single "what colors does this land make" signal
for both the pick value and the manabase, unioning intrinsic basic-land-type
mana with Effect::Mana abilities (incl. filter-land ChoiceAmongCombinations).
Dedups the mulligan keepables' private copy of the same logic.

Tests: draft_eval (subtype dual, ability dual, mono, colorless-only) and
suggest (on-color admitted, off-color rejected, total conserved, no-db noop).

Reported via Discord #ai-suggestions ("Nonbasic Lands in Draft").
@matthewevans
matthewevans enabled auto-merge May 31, 2026 14:33

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements full-pipeline support for Omo, Queen of Vesuva, introducing the parsing, layer evaluation, and integration tests for everything counters that grant all land and creature types. It also delivers major AI enhancements, including a shared mana_colors module for identifying fixing lands in draft and mulligan evaluation, and several new tactical policies: planeswalker loyalty-ability optimization, equipment re-equip prevention, bounce-land sequencing, and condition-gated activation prevention (e.g., for hideaway lands). Additionally, the combat AI is upgraded to support planeswalker attack redirection, prevent unfavorable commander trades, and avoid throwing lifelinkers into pure-loss blocks. I have no feedback to provide as no review comments were submitted.

…in became QuantityExpr

MultiTargetSpec.min was parameterized from i32 to QuantityExpr on main
(CR 115.1d up-to-N targeting). The Omo dual-target parser test compared
s.min == 0 against the old integer type, which no longer compiles against
the new QuantityExpr min (E0308). Use a variant match for the Fixed(0)
case, matching how the rest of the engine constructs/handles QuantityExpr.
@matthewevans
matthewevans added this pull request to the merge queue May 31, 2026
Merged via the queue into main with commit fe7f577 May 31, 2026
9 checks passed
@matthewevans
matthewevans deleted the ship/ai-suggestions-batch branch May 31, 2026 15:12
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.

1 participant