enjine 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/SUPPORTED.md +12 -9
  4. data/docs/MECHANICS_COVERAGE.md +1 -1
  5. data/docs/guides/card-data-format.md +23 -4
  6. data/docs/plans/dsl-gaps.md +606 -0
  7. data/docs/plans/standard.md +3 -0
  8. data/docs/reference/abilities.md +33 -7
  9. data/docs/reference/costs.md +11 -1
  10. data/docs/reference/decisions.md +1 -1
  11. data/docs/reference/effects.md +30 -9
  12. data/docs/reference/triggers.md +3 -2
  13. data/lib/enjine/actions/special_action.rb +4 -2
  14. data/lib/enjine/engine/casting/alt_casting.rb +18 -2
  15. data/lib/enjine/engine/casting/casting.rb +3 -3
  16. data/lib/enjine/engine/casting/costs.rb +5 -1
  17. data/lib/enjine/engine/casting/permissions.rb +1 -1
  18. data/lib/enjine/engine/characteristics.rb +0 -13
  19. data/lib/enjine/engine/defined_power_toughness.rb +30 -0
  20. data/lib/enjine/engine/mana_sources.rb +8 -1
  21. data/lib/enjine/engine/resolution.rb +1 -0
  22. data/lib/enjine/engine/triggers.rb +1 -0
  23. data/lib/enjine/engine/turn_history.rb +25 -1
  24. data/lib/enjine/engine/zones.rb +16 -6
  25. data/lib/enjine/mechanics/abilities/additional_triggers.rb +23 -11
  26. data/lib/enjine/mechanics/abilities/cost_changes.rb +20 -7
  27. data/lib/enjine/mechanics/abilities/mana_ability.rb +21 -6
  28. data/lib/enjine/mechanics/abilities/stack_abilities.rb +13 -9
  29. data/lib/enjine/mechanics/abilities/static.rb +13 -7
  30. data/lib/enjine/mechanics/costs/sacrifice.rb +28 -2
  31. data/lib/enjine/mechanics/effects/become.rb +8 -4
  32. data/lib/enjine/mechanics/effects/choose_cards.rb +9 -7
  33. data/lib/enjine/mechanics/effects/delayed_triggers.rb +2 -1
  34. data/lib/enjine/mechanics/effects/search.rb +15 -8
  35. data/lib/enjine/mechanics/keywords/plot.rb +79 -0
  36. data/lib/enjine/mechanics/triggers/casting.rb +8 -6
  37. data/lib/enjine/mechanics/values.rb +82 -14
  38. data/lib/enjine/version.rb +1 -1
  39. metadata +4 -1
@@ -107,6 +107,9 @@ rotation is announced, drop the rotating sets from this table.
107
107
 
108
108
  ## Mechanics gaps
109
109
 
110
+ For the gaps behind deckfinder's DSL FAIL and PARTIAL cards, ranked by how many
111
+ cards each blocks, with a quick-wins list, see [dsl-gaps.md](dsl-gaps.md).
112
+
110
113
  Census taken 2026-11-09 against `SUPPORTED.md` at this commit. It lists what can't
111
114
  yet be written in the card DSL. Card lists came from the Scryfall API (main sets,
112
115
  `unique=cards`, no bonus sheets) and were checked outside the repository; nothing
@@ -82,18 +82,25 @@ Rules: CR 305.2, CR 305.2a, CR 305.2b, CR 604.1
82
82
 
83
83
  ## additional_triggers
84
84
 
85
- While this permanent is on the battlefield, a triggered ability of a permanent its controller controls that triggers because of `cause` triggers an additional time. `cause`: you_gain_life (its controller gaining life).
85
+ While this permanent is on the battlefield, a triggered ability of a permanent its controller controls triggers an additional time if it triggers because of `cause` (you_gain_life: its controller gaining life) and/or its permanent matches `of` (a filter: "triggered abilities of Allies you control", {"creature": {"other": true, "subtypes": "chosen"}}). At least one of them.
86
86
 
87
- Rules: CR 603.2d, CR 119.9
87
+ Rules: CR 603.2d, CR 119.9, CR 607.2d
88
88
 
89
89
  | Parameter | Description |
90
90
  | --- | --- |
91
91
  | `cause` | you_gain_life |
92
+ | `of` | filter of the permanent whose ability triggers |
92
93
 
93
94
  ```json
94
95
  {
95
96
  "additional_triggers": {
96
- "cause": "you_gain_life"
97
+ "of": {
98
+ "creature": {
99
+ "subtypes": [
100
+ "ally"
101
+ ]
102
+ }
103
+ }
97
104
  }
98
105
  }
99
106
  ```
@@ -397,13 +404,13 @@ Rules: CR 717.2a, CR 717.2b, CR 717.2d, CR 717.3, CR 602.5d
397
404
 
398
405
  ## cost_modifier
399
406
 
400
- While on the battlefield: spells cast by `caster` (you, opponent or any; default you) whose card types match `spells` ({"any_of": [types], "not": [types]}, default all spells) cost `reduce` less or `increase` more generic mana. `from` / `not_from`: only spells cast from (not from) that zone (hand | graveyard | exile | library | outside).
407
+ While on the battlefield: spells cast by `caster` (you, opponent or any; default you) whose card types match `spells` ({"any_of": [types], "not": [types]}, default all spells; `subtypes` and `colors` narrow it: "Dragon spells", "red spells") cost `reduce` less or `increase` more generic mana. `from` / `not_from`: only spells cast from (not from) that zone (hand | graveyard | exile | library | outside).
401
408
 
402
409
  Rules: CR 601.2f, CR 118.7a, CR 604.1, CR 601.2a
403
410
 
404
411
  | Parameter | Description |
405
412
  | --- | --- |
406
- | `spells` | {"any_of": [types], "not": [types]} (optional) |
413
+ | `spells` | {"any_of": [types], "not": [types], "subtypes": [subtypes], "colors": [colors]} (optional) |
407
414
  | `caster` | you | opponent | any |
408
415
  | `reduce` | amount |
409
416
  | `increase` | amount |
@@ -1080,7 +1087,7 @@ Rules: CR 606.3, CR 606.4, CR 606.5, CR 602.2b
1080
1087
 
1081
1088
  | Parameter | Description |
1082
1089
  | --- | --- |
1083
- | `cost` | integer, e.g. 1 for +1 or -2 |
1090
+ | `cost` | integer, e.g. 1 for +1 or -2; or "-X" (X is announced, at most its loyalty) |
1084
1091
  | `targets` | list of target filters |
1085
1092
  | `effects` | list of effects |
1086
1093
 
@@ -1107,7 +1114,7 @@ Rules: CR 606.3, CR 606.4, CR 606.5, CR 602.2b
1107
1114
 
1108
1115
  ## mana
1109
1116
 
1110
- An activated mana ability: pay the cost, add the mana to your mana pool immediately. It does not use the stack and can be activated whenever you have priority. `produce`: the fixed mana (`amount` times). Or `color`: `amount` mana (default 1) of one color you choose as you activate it (`"any_one"`) or in any combination of colors (`"any"`); `colors` limits the choice. `"chosen"`: of the color chosen as this entered (choose_color). `spend_only` restricts how the mana can be spent. `only_if`: "Activate only if ...".
1117
+ An activated mana ability: pay the cost, add the mana to your mana pool immediately. It does not use the stack and can be activated whenever you have priority. `produce`: the fixed mana (`amount` times). Or `color`: `amount` mana (default 1) of one color you choose as you activate it (`"any_one"`) or in any combination of colors (`"any"`); `colors` limits the choice. `"chosen"`: of the color chosen as this entered (choose_color). `spend_only` restricts how the mana can be spent. `only_if`: "Activate only if ...". `limit: "once_each_turn"`: "Activate only once each turn."
1111
1118
 
1112
1119
  Rules: CR 605.1a, CR 605.3a, CR 605.3b, CR 106.1a, CR 106.4, CR 106.6, CR 607.2d, CR 602.5b
1113
1120
 
@@ -1120,6 +1127,7 @@ Rules: CR 605.1a, CR 605.3a, CR 605.3b, CR 106.1a, CR 106.4, CR 106.6, CR 607.2d
1120
1127
  | `colors` | list of colors to choose from (default all five) |
1121
1128
  | `spend_only` | {"spells": card spec, "abilities": card spec, "from" | "not_from": zone} |
1122
1129
  | `only_if` | condition: it can be activated only while it holds (CR 602.5b) |
1130
+ | `limit` | "once_each_turn" (CR 602.5b) |
1123
1131
 
1124
1132
  ```json
1125
1133
  {
@@ -1264,6 +1272,24 @@ Rules: CR 702.11c, CR 115.4, CR 508.1c, CR 402.2, CR 514.1, CR 615.1, CR 615.1a,
1264
1272
  }
1265
1273
  ```
1266
1274
 
1275
+ ## plot
1276
+
1277
+ Plot `cost`: during your main phase, while the stack is empty, you may pay `cost` and exile this card from your hand (special action `{type: "special_action"}`). It becomes plotted: on a later turn you may cast it from exile without paying its mana cost, during your main phase while the stack is empty (cast option `via: "plotted"`).
1278
+
1279
+ Rules: CR 702.170a, CR 702.170b, CR 702.170d, CR 116.2k
1280
+
1281
+ | Parameter | Description |
1282
+ | --- | --- |
1283
+ | `cost` | mana cost string |
1284
+
1285
+ ```json
1286
+ {
1287
+ "plot": {
1288
+ "cost": "{1}{R}"
1289
+ }
1290
+ }
1291
+ ```
1292
+
1267
1293
  ## prevent_damage
1268
1294
 
1269
1295
  While this permanent is on the battlefield, all damage from sources in `by` to recipients in `to` (damage scopes as in damage_replacement, both optional; `combat`: combat | noncombat) is prevented. "To and by this creature" is two of them. `then`: effects right after it prevents damage ("prevent that damage and put that many +1/+1 counters on him"), reading {"triggering": "amount"} (the damage prevented), "object" (the source) and "player" (its controller).
@@ -133,6 +133,16 @@ Rules: CR 406.1, CR 601.2h, CR 602.2b, CR 118.3
133
133
  }
134
134
  ```
135
135
 
136
+ ## exile_self
137
+
138
+ Exile the permanent the ability belongs to ("Exile this enchantment: ...").
139
+
140
+ Rules: CR 406.1, CR 602.2b, CR 118.3
141
+
142
+ ```json
143
+ "exile_self"
144
+ ```
145
+
136
146
  ## exile_self_from_graveyard
137
147
 
138
148
  Exile this card from your graveyard. An ability with this cost is activated from your graveyard.
@@ -256,7 +266,7 @@ Rules: CR 701.21a, CR 602.2b, CR 118.3
256
266
 
257
267
  ## sacrifice_permanent
258
268
 
259
- Sacrifice `count` permanents you control matching `filter` (`other: true`: not the source). Chosen while casting or activating, sacrificed as it becomes cast; the sacrificed permanent's last known types and subtypes are remembered (condition `sacrificed`).
269
+ Sacrifice `count` permanents you control matching `filter` (`other: true`: not the source). Chosen while casting or activating, sacrificed as it becomes cast; the sacrificed permanent's last known types and subtypes are remembered (condition `sacrificed`), and its power, toughness and mana value (amount `sacrificed`).
260
270
 
261
271
  Rules: CR 701.21a, CR 601.2h, CR 602.2b, CR 118.3, CR 608.2h
262
272
 
@@ -99,4 +99,4 @@ Untap step: `{type: "untap", object: id}` or `{type: "keep_tapped", object: id}`
99
99
  - `cast` — `{type: "cast", object: id}` — start casting a card from your hand (CR 601.2). `{type: "cast", object: id, via: method}` casts a card from another zone (graveyard, exile, the top of your library) by the permission or alternative cost `via` names, e.g. beam me up (CR 601.3, 702.196). A card outside the game is named `"outside:<card id>"` (CR 400.11b). `{type: "cast", object: id, face: 1}` casts another face of a card in your hand: a modal double-faced card's back face, an Adventure or an Omen (CR 712.8b, 716.3, 721.3). Offered only when timing allows, targets exist and enough mana is available.
100
100
  - `pass` — `{type: "pass"}` — pass priority. When all players pass in succession with an empty stack, the step ends (CR 117.4).
101
101
  - `play_land` — `{type: "play_land", object: id}` — special action: your main phase, empty stack, one land per turn plus additional land plays (CR 305.1, 305.2). With `via`: a land from another zone that an effect lets you play. With `face: 1`: a modal double-faced card played with its land back face up (CR 712.9).
102
- - `special_action` — `{type: "special_action", object: id, ability: ref}` — a special action of a card in your hand: foretell (during your turn, pay {2} and exile it face down, CR 116.2h) or suspend (when you could begin to cast it, pay its suspend cost and exile it with time counters, CR 116.2f).
102
+ - `special_action` — `{type: "special_action", object: id, ability: ref}` — a special action of a card in your hand: foretell (during your turn, pay {2} and exile it face down, CR 116.2h) or suspend (when you could begin to cast it, pay its suspend cost and exile it with time counters, CR 116.2f) or plot (in your main phase with an empty stack, pay its plot cost and exile it, CR 116.2k).
@@ -224,7 +224,7 @@ Rules: CR 701.3a, CR 701.3b, CR 701.3c, CR 301.5c
224
224
 
225
225
  ## become
226
226
 
227
- Each referenced permanent becomes what the parameters say, for `duration` (end_of_turn, default; indefinite: as long as it stays on the battlefield; while_source_on_battlefield: for as long as this effect's source remains on the battlefield; while_you_control_source: for as long as you control it): it has the card `types`, `supertypes` and `subtypes` in addition to its other types (layer 4, "becomes an artifact creature"), its creature types are exactly `creature_types` (layer 4), its colors are `colors` (layer 5), with `loses_abilities: true` it loses all the abilities it had (layer 6; abilities granted later, keyword counters put on it later and this effect's own `keywords` and `abilities` stay, CR 613.7), it has the `keywords` and `abilities` (layer 6: triggered, activated, mana, play_from or additional_land_plays abilities) and its base power and toughness are `base_power_toughness` (layer 7b). A permanent that becomes a creature has its printed power and toughness (a Vehicle's), or 0/0.
227
+ Each referenced permanent becomes what the parameters say, for `duration` (end_of_turn, default; until_your_next_turn: until your next turn begins; indefinite: as long as it stays on the battlefield; while_source_on_battlefield: for as long as this effect's source remains on the battlefield; while_you_control_source: for as long as you control it): it has the card `types`, `supertypes` and `subtypes` in addition to its other types (layer 4, "becomes an artifact creature"), its creature types are exactly `creature_types` (layer 4), its colors are `colors` (layer 5), with `loses_abilities: true` it loses all the abilities it had (layer 6; abilities granted later, keyword counters put on it later and this effect's own `keywords` and `abilities` stay, CR 613.7), it has the `keywords` and `abilities` (layer 6: triggered, activated, mana, play_from or additional_land_plays abilities) and its base power and toughness are `base_power_toughness` (layer 7b). A permanent that becomes a creature has its printed power and toughness (a Vehicle's), or 0/0.
228
228
 
229
229
  Rules: CR 611.2a, CR 611.2b, CR 611.2c, CR 613.1d, CR 613.1e, CR 613.1f, CR 613.4b, CR 613.7b, CR 205.1a, CR 205.1b, CR 301.7b, CR 514.2
230
230
 
@@ -239,7 +239,7 @@ Rules: CR 611.2a, CR 611.2b, CR 611.2c, CR 613.1d, CR 613.1e, CR 613.1f, CR 613.
239
239
  | `keywords` | list of keywords it gains |
240
240
  | `base_power_toughness` | {"power": n, "toughness": n} |
241
241
  | `abilities` | list of abilities it gains |
242
- | `duration` | end_of_turn | indefinite | while_source_on_battlefield | while_you_control_source |
242
+ | `duration` | end_of_turn | until_your_next_turn | indefinite | while_source_on_battlefield | while_you_control_source |
243
243
  | `to` | selector |
244
244
 
245
245
  ```json
@@ -461,9 +461,9 @@ Rules: CR 205.2a, CR 607.2d, CR 608.2c
461
461
 
462
462
  ## choose_cards
463
463
 
464
- You choose `count` (default 1; "any": any number) cards matching `for` from `from`: a selector (e.g. {"result": 0}, the cards an earlier effect milled) or a player's zone, {"hand": selector} / {"graveyard": selector}. `optional`: you may choose fewer. Then they are put into `put` (`tapped` onto the battlefield, under your control; exiled cards `link`ed to the source). `look`: you look at the whole zone first; `reveal`: "chosen" reveals what you chose, "all" the whole set. `then` runs if a card was chosen, `otherwise` if none was ("if you can't"). `random: true`: the cards are picked at random (the game's random number generator) instead of chosen.
464
+ You choose `count` (default 1; "any": any number) cards matching `for` from `from`: a selector (e.g. {"result": 0}, the cards an earlier effect milled) or a player's zone, {"hand": selector} / {"graveyard": selector}. `optional`: you may choose fewer. Then they are put into `put` (`discard`: from a hand, its owner discards them; `tapped` onto the battlefield, under your control; exiled cards `link`ed to the source). `look`: you look at the whole zone first; `reveal`: "chosen" reveals what you chose, "all" the whole set. `then` runs if a card was chosen, `otherwise` if none was ("if you can't"). `random: true`: the cards are picked at random (the game's random number generator) instead of chosen.
465
465
 
466
- Rules: CR 701.20a, CR 701.20e, CR 400.7j, CR 608.2c, CR 607.2a, CR 118.12, CR 701.9b
466
+ Rules: CR 701.20a, CR 701.20e, CR 400.7j, CR 608.2c, CR 607.2a, CR 118.12, CR 701.9a, CR 701.9b
467
467
 
468
468
  | Parameter | Description |
469
469
  | --- | --- |
@@ -471,7 +471,7 @@ Rules: CR 701.20a, CR 701.20e, CR 400.7j, CR 608.2c, CR 607.2a, CR 118.12, CR 70
471
471
  | `for` | card spec |
472
472
  | `count` | integer or "any" |
473
473
  | `optional` | boolean |
474
- | `put` | hand | graveyard | exile | battlefield | library_top | library_bottom |
474
+ | `put` | hand | graveyard | exile | battlefield | library_top | library_bottom | discard |
475
475
  | `tapped` | boolean |
476
476
  | `link` | boolean |
477
477
  | `look` | boolean |
@@ -1361,14 +1361,15 @@ Rules: CR 119.3
1361
1361
 
1362
1362
  ## grant
1363
1363
 
1364
- Each referenced permanent (or spell on the stack) gains the `keywords` until end of turn.
1364
+ Each referenced permanent (or spell on the stack) gains the `keywords` until end of turn (`duration: "until_your_next_turn"`: until its controller's next turn begins).
1365
1365
 
1366
- Rules: CR 611.2a, CR 611.2c, CR 613.1f, CR 514.2
1366
+ Rules: CR 611.2a, CR 611.2c, CR 613.1f, CR 514.2, CR 500.1
1367
1367
 
1368
1368
  | Parameter | Description |
1369
1369
  | --- | --- |
1370
1370
  | `keywords` | list of keywords |
1371
1371
  | `to` | selector |
1372
+ | `duration` | end_of_turn | until_your_next_turn |
1372
1373
 
1373
1374
  ```json
1374
1375
  {
@@ -1657,6 +1658,26 @@ Rules: CR 702.11c, CR 115.4, CR 508.1c, CR 402.2, CR 514.1, CR 615.1, CR 615.1a,
1657
1658
  }
1658
1659
  ```
1659
1660
 
1661
+ ## plot
1662
+
1663
+ Each referenced card or spell is exiled (if it isn't already) and becomes plotted: its owner may cast it from exile without paying its mana cost during their main phase while the stack is empty, on any later turn (cast option `via: "plotted"`).
1664
+
1665
+ Rules: CR 702.170c, CR 702.170d, CR 406.1
1666
+
1667
+ | Parameter | Description |
1668
+ | --- | --- |
1669
+ | `to` | selector (a spell, a card, or an exiled card such as {"result": 0}) |
1670
+
1671
+ ```json
1672
+ {
1673
+ "plot": {
1674
+ "to": {
1675
+ "target": 0
1676
+ }
1677
+ }
1678
+ }
1679
+ ```
1680
+
1660
1681
  ## prepare
1661
1682
 
1662
1683
  Each referenced permanent (default: this one) becomes prepared, if it has a prepare spell and isn't prepared already: its controller creates a copy of the prepare spell in exile and may cast that copy while the permanent stays prepared. Casting it unprepares the permanent.
@@ -2054,7 +2075,7 @@ Rules: CR 701.22a
2054
2075
 
2055
2076
  ## search_library
2056
2077
 
2057
- Search your library for up to `count` cards matching `for`, then put them into your hand (or onto the battlefield, tapped if `tapped`, with `counters`) and shuffle. With a stated quality you may find fewer (or none); a search for just "a card" must find one if it can. `reveal` shows the found cards to every player. `searcher` names another player who searches their own library (e.g. {"controller_of": {"target": 0}}); `may: true` lets the searcher decline (then nothing is shuffled). `different_names: true`: the cards found must all have different names ("basic land cards with different names").
2078
+ Search your library for up to `count` cards matching `for`, then put them into your hand (or onto the battlefield, tapped if `tapped`, with `counters`; or into your graveyard) and shuffle. `put: library_top`: shuffle, then put the found card (one) on top. Computed bounds in `for` are worked out as the effect resolves. With a stated quality you may find fewer (or none); a search for just "a card" must find one if it can. `reveal` shows the found cards to every player. `searcher` names another player who searches their own library (e.g. {"controller_of": {"target": 0}}); `may: true` lets the searcher decline (then nothing is shuffled). `different_names: true`: the cards found must all have different names ("basic land cards with different names").
2058
2079
 
2059
2080
  Rules: CR 701.23a, CR 701.23b, CR 701.23d, CR 701.23e, CR 701.20a, CR 701.24a, CR 701.24b, CR 118.12, CR 110.2a
2060
2081
 
@@ -2062,7 +2083,7 @@ Rules: CR 701.23a, CR 701.23b, CR 701.23d, CR 701.23e, CR 701.20a, CR 701.24a, C
2062
2083
  | --- | --- |
2063
2084
  | `for` | card spec: "card" (default), {"types"|"subtypes"|"supertypes": [...], "name": "..."} or {"any_of": [spec, ...]} |
2064
2085
  | `count` | integer (default 1), "any" (any number), "x" or an amount ("up to that many") |
2065
- | `put` | "hand" (default) or "battlefield" |
2086
+ | `put` | "hand" (default), "battlefield", "graveyard" or "library_top" (count 1) |
2066
2087
  | `tapped` | true: the cards enter tapped (with put: battlefield) |
2067
2088
  | `counters` | {counter name: n}: the cards enter with these counters (with put: battlefield) |
2068
2089
  | `reveal` | true: reveal the found cards |
@@ -27,9 +27,9 @@ Rules: CR 603.2, CR 603.3b, CR 508.1, CR 707.10
27
27
 
28
28
  ## activates_ability
29
29
 
30
- A player (`controller`: you (default) | opponent | any) activates an ability that isn't a mana ability. `source`: optional filter on the source's card; `name: "chosen"`: only a source with the name chosen as this permanent entered. `exhaust: true`: only an exhaust ability ("whenever you activate an exhaust ability", CR 702.177). `boast: true`: only a boast ability ("whenever a creature you control boasts", CR 702.142b).
30
+ A player (`controller`: you (default) | opponent | any) activates an ability that isn't a mana ability. `source`: optional filter on the source's card; `name: "chosen"`: only a source with the name chosen as this permanent entered. `exhaust: true`: only an exhaust ability ("whenever you activate an exhaust ability", CR 702.177). `boast: true`: only a boast ability ("whenever a creature you control boasts", CR 702.142b). `loyalty: true`: only a loyalty ability (CR 606).
31
31
 
32
- Rules: CR 602.2, CR 605.1a, CR 603.2, CR 201.4, CR 702.177a, CR 702.142b
32
+ Rules: CR 602.2, CR 605.1a, CR 603.2, CR 201.4, CR 702.177a, CR 702.142b, CR 606.1
33
33
 
34
34
  | Parameter | Description |
35
35
  | --- | --- |
@@ -38,6 +38,7 @@ Rules: CR 602.2, CR 605.1a, CR 603.2, CR 201.4, CR 702.177a, CR 702.142b
38
38
  | `name` | "chosen" |
39
39
  | `exhaust` | true: only exhaust abilities |
40
40
  | `boast` | true: only boast abilities |
41
+ | `loyalty` | true: only loyalty abilities |
41
42
 
42
43
  ```json
43
44
  {
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Enjine
4
4
  module Actions
5
- # CR 116.2f, 116.2h: special actions that pay a cost and exile a card from your hand (foretell, suspend).
5
+ # CR 116.2f, 116.2h, 116.2k: special actions that pay a cost and exile a card from your hand (foretell,
6
+ # suspend, plot).
6
7
  # The card's ability names the cost, when the action is allowed and what it does; the cost is paid in a
7
8
  # "pay" frame (Engine::Payment#begin_payment!), which can be cancelled, and the player gets priority
8
9
  # again afterwards (CR 116.3).
@@ -10,7 +11,8 @@ module Enjine
10
11
  def self.doc
11
12
  '`{type: "special_action", object: id, ability: ref}` — a special action of a card in your hand: foretell ' \
12
13
  "(during your turn, pay {2} and exile it face down, CR 116.2h) or suspend (when you could begin to cast it, " \
13
- "pay its suspend cost and exile it with time counters, CR 116.2f)."
14
+ "pay its suspend cost and exile it with time counters, CR 116.2f) or plot (in your main phase with an empty " \
15
+ "stack, pay its plot cost and exile it, CR 116.2k)."
14
16
  end
15
17
 
16
18
  def self.options(engine, index)
@@ -8,7 +8,8 @@ module Enjine
8
8
  # {"via" => "alt:<ref>", "play" => "cast" | "play", "keyword" => "flashback", "instant" => true (sneak),
9
9
  # "alt" => {"cost" => "{2}{R}", "ref" => ref, "exile" => true, "returns" => "tapped" | "unblocked",
10
10
  # "harmonize" => true}}
11
- # and a warped card in exile has the method {"via" => "warped", "play" => "cast"} (CR 702.185a).
11
+ # and a warped card in exile has the method {"via" => "warped", "play" => "cast"} (CR 702.185a); a plotted one
12
+ # {"via" => "plotted", "play" => "cast", "alt" => {"free" => true}, "sorcery" => true} (CR 702.170d).
12
13
  #
13
14
  # How a spell was cast is remembered on the spell (memory "cast_from": the zone, "cast_with": the keyword)
14
15
  # and on the permanent it becomes (CR 400.7d), and each player's turn log keeps the keywords of the
@@ -32,7 +33,14 @@ module Enjine
32
33
  ability = inst.ability
33
34
  ability.method_for(inst.ref) if ability.kind == :alternative_cost && ability.functions?(self, obj, index)
34
35
  end
35
- own + warped_methods(index, obj)
36
+ own + warped_methods(index, obj) + plotted_methods(obj)
37
+ end
38
+
39
+ # CR 702.170d: a plotted card may be cast without paying its mana cost during its owner's main phase while
40
+ # the stack is empty, on any turn after the one it became plotted in (a sorcery's timing, whatever its type).
41
+ def plotted_methods(obj)
42
+ turn = obj.zone == :exile && obj.memory["plotted"]
43
+ turn && state.turn > turn ? [{ "via" => "plotted", "play" => "cast", "alt" => Permissions::FREE, "sorcery" => true }] : []
36
44
  end
37
45
 
38
46
  # CR 702.185a: "Its owner may cast this card after the current turn has ended for as long as it remains
@@ -42,6 +50,14 @@ module Enjine
42
50
  turn && state.turn > turn ? [{ "via" => "warped", "play" => "cast" }] : []
43
51
  end
44
52
 
53
+ # CR 304.1, 307.1: an instant or flash spell (or a method cast "any time you could cast an instant") at
54
+ # instant timing; anything else, and a plotted card whatever its type (CR 702.170d), at sorcery timing.
55
+ def method_timing?(index, chars, method)
56
+ return true if sorcery_timing?(index)
57
+
58
+ !method["sorcery"] && (chars.type?(:instant) || chars.keyword?(:flash) || method["instant"])
59
+ end
60
+
45
61
  # CR 702.187b: mayhem needs the card to have been discarded this turn (TurnHistory stamps it).
46
62
  def discarded_this_turn?(obj) = !turned_memory(obj, "discarded").nil?
47
63
 
@@ -277,18 +277,18 @@ module Enjine
277
277
  end
278
278
 
279
279
  # The non-mana costs (CR 602.2b): its own ({T}, sacrifice it, exile it from the graveyard), then
280
- # the chosen ones (sacrifice another creature, remove counters). A source sacrificed as a cost is
280
+ # the chosen ones (sacrifice another creature, remove counters). A source sacrificed or exiled as a cost is
281
281
  # remembered as it last existed (its counters, CR 608.2h).
282
282
  def pay_ability_costs!(ctx, obj)
283
283
  source = object(ctx["source"])
284
284
  ability = chars(source).ability(ctx["ability"])
285
- obj.memory["source_counters"] = source.counters.dup if ability.costs.any?(Mechanics::Costs::SacrificeSelf)
285
+ obj.memory["source_counters"] = source.counters.dup if ability.costs.any? { Mechanics::Costs::LEAVING_SELF.include?(it.class) }
286
286
  frame_costs(ctx).reject(&:mana?).each { it.pay!(self, source) } # the chosen option of a one_of (CR 601.2b)
287
287
  pay_chosen_costs!(ctx)
288
288
  return unless ability.kind == :loyalty
289
289
 
290
290
  source.loyalty_turn = state.turn
291
- cost = ability.loyalty_cost
291
+ cost = ability.x_loyalty? ? -ctx["x"].to_i : ability.loyalty_cost
292
292
  cost.negative? ? remove_counters!(source, "loyalty", -cost) : add_counters!(source, "loyalty", cost)
293
293
  end
294
294
 
@@ -75,7 +75,7 @@ module Enjine
75
75
  # the mana cost or a cost such as "pay X life" or "exile X cards", X is 0.
76
76
  def ask_x!(ctx)
77
77
  return false unless ctx["x"].nil?
78
- return (ctx["x"] = 0) && false unless base_cost(ctx)&.x_symbols.to_i.positive? || x_costs(ctx).any?
78
+ return (ctx["x"] = 0) && false unless base_cost(ctx)&.x_symbols.to_i.positive? || x_costs(ctx).any? || x_loyalty?(ctx)
79
79
 
80
80
  ask!("kind" => "choose_x", "player" => ctx["player"], "max" => x_limit(ctx))
81
81
  true
@@ -85,6 +85,9 @@ module Enjine
85
85
  # graveyard" (Costs::CardsCost).
86
86
  def x_costs(ctx) = casting_costs(ctx).select { it.respond_to?(:x?) && it.x? }
87
87
 
88
+ # A "-X" loyalty ability being activated (CR 606.4, 107.3).
89
+ def x_loyalty?(ctx) = ctx["kind"] == "ability" && casting_ability.respond_to?(:x_loyalty?) && casting_ability.x_loyalty?
90
+
88
91
  # A {T} ability's own source can't pay for it.
89
92
  def payment_excluded_source(ctx)
90
93
  ctx["type"] == "cast" && ctx["kind"] == "ability" && needs_tap?(casting_ability) ? ctx["source"] : nil
@@ -94,6 +97,7 @@ module Enjine
94
97
  # "pay X life" no more than their life total (CR 119.4), for "exile X cards" no more than there are (CR 118.3).
95
98
  def x_limit(ctx)
96
99
  limits = x_costs(ctx).map { it.x_limit(self, cost_payer(ctx)) }
100
+ limits << loyalty(object(ctx["source"])) if x_loyalty?(ctx) # CR 118.3: no more counters than it has
97
101
  symbols = base_cost(ctx)&.x_symbols.to_i
98
102
  return limits.min if symbols.zero?
99
103
 
@@ -162,7 +162,7 @@ module Enjine
162
162
 
163
163
  c = chars(obj)
164
164
  return false if c.land? || (method["alt"].nil? && c.mana_cost.nil?) # CR 305.9
165
- return false unless c.type?(:instant) || c.keyword?(:flash) || method["instant"] || sorcery_timing?(index)
165
+ return false unless method_timing?(index, c, method)
166
166
  return false if cast_forbidden?(index, obj) # CR 101.2
167
167
  return false if returns_creature?(method["alt"]) && return_candidates(index, method["alt"]).empty?
168
168
  return false unless additional_costs_payable?(obj) && alt_parts_payable?(obj, method) # CR 601.2f
@@ -134,12 +134,6 @@ module Enjine
134
134
  granted.empty? ? base : base.with(abilities: (base.abilities + granted).freeze)
135
135
  end
136
136
 
137
- # CR 604.3, 707.9d: a characteristic-defining power/toughness, unless a copy exception sets them.
138
- def defined_pt_copied?(obj)
139
- except = copy_exceptions(obj)
140
- definition(obj).defined_pt? && !(except&.key?("power") || except&.key?("toughness"))
141
- end
142
-
143
137
  # Layers 1-4: the object's own changes and resolved effects that add types (#own_layers),
144
138
  # then static abilities that add card types ("this Vehicle is an artifact creature", CR 205.1b), then effects
145
139
  # that set its creature types (CR 205.1a, 613.1d), statics before resolved effects, each in timestamp order.
@@ -239,13 +233,6 @@ module Enjine
239
233
  sets.max_by(&:first)&.last
240
234
  end
241
235
 
242
- # CR 604.3, 613.4a: power/toughness from a characteristic-defining ability, in every zone.
243
- def defined_power_toughness(obj, base, defn)
244
- ctx = Mechanics::EffectContext.new(obj.controller, obj.id, obj.id, [], [])
245
- base.with(power: defn.power_amount ? defn.power_amount.value(self, ctx) : base.power,
246
- toughness: defn.toughness_amount ? defn.toughness_amount.value(self, ctx) : base.toughness)
247
- end
248
-
249
236
  # [[source object, StaticAbility], ...] for permanents on the battlefield (printed abilities:
250
237
  # an ability granting static abilities would need layer dependencies, CR 613.8).
251
238
  def static_sources
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Power and toughness defined by a characteristic-defining ability ("*", CR 604.3), used by the layer pass
6
+ # (Engine::Characteristics) on the battlefield and in every other zone.
7
+ module DefinedPowerToughness
8
+ # CR 604.3, 707.9d: a characteristic-defining power/toughness, unless a copy exception sets them.
9
+ def defined_pt_copied?(obj)
10
+ except = copy_exceptions(obj)
11
+ definition(obj).defined_pt? && !(except&.key?("power") || except&.key?("toughness"))
12
+ end
13
+
14
+ # CR 604.3, 613.4a: the defined power/toughness. Its amount may count the object itself ("card types among
15
+ # cards in all graveyards" while it's in one); it can't read power or toughness (CardDefinition#read_pt), so
16
+ # a nested look at the object while it's being computed skips the definition.
17
+ def defined_power_toughness(obj, base, defn)
18
+ return base if (defining = (@defining_pt ||= {})).key?(obj.id)
19
+
20
+ defining[obj.id] = ctx = Mechanics::EffectContext.new(obj.controller, obj.id, obj.id, [], [])
21
+ base.with(power: defn.power_amount ? defn.power_amount.value(self, ctx) : base.power,
22
+ toughness: defn.toughness_amount ? defn.toughness_amount.value(self, ctx) : base.toughness)
23
+ ensure
24
+ @defining_pt.delete(obj.id) if ctx
25
+ end
26
+ end
27
+
28
+ include DefinedPowerToughness
29
+ end
30
+ end
@@ -94,11 +94,17 @@ module Enjine
94
94
  obj = object(id)
95
95
  next unless obj.controller == index && !mana_abilities_forbidden?(obj) # Engine::Rules
96
96
 
97
- chars(obj).mana_abilities.each { |inst| out << [id, inst.ref] if inst.ability.activatable?(self, obj) }
97
+ chars(obj).mana_abilities.each { |inst| out << [id, inst.ref] if mana_ability_usable?(obj, inst) }
98
98
  end
99
99
  out
100
100
  end
101
101
 
102
+ # CR 602.5b: "Activate only once each turn" counts on the source, as for other activated abilities.
103
+ def mana_ability_usable?(obj, inst)
104
+ per_turn = inst.ability.per_turn
105
+ (per_turn.nil? || activations_this_turn(obj, inst.ref) < per_turn) && inst.ability.activatable?(self, obj)
106
+ end
107
+
102
108
  # Activation options for mana abilities: one per source, or one per mana it could add when the
103
109
  # mana is chosen as it's activated (`mana`: e.g. "{G}{G}", CR 605.3b).
104
110
  def mana_activation_options(sources)
@@ -118,6 +124,7 @@ module Enjine
118
124
  ability = chars(obj).ability(ability_ref) or raise ArgumentError, "#{id} has no ability #{ability_ref}"
119
125
  chosen = mana && Mana.parse_produce(mana, [], "mana")
120
126
  from = mana_provenance(id) # before a cost sacrifices it (a Treasure)
127
+ record_activation!("source" => id, "ability" => ability_ref) if ability.per_turn
121
128
  pay_from_pool!(index, ability.mana_cost, id) if ability.mana_cost
122
129
  ability.costs.each { |cost| cost.pay!(self, obj) }
123
130
  produced = chosen || ability.outcomes(self, obj).first # CR 605.3b: a fixed amount is counted as it resolves
@@ -67,6 +67,7 @@ module Enjine
67
67
  "object" => obj.id, "targets" => targets, "results" => {} }
68
68
  frame["ctx"]["linked"] = obj.memory["results"] if obj.memory.key?("results") # a delayed trigger's (CR 603.7c)
69
69
  frame["ctx"]["linked_amounts"] = obj.memory["amounts"] if obj.memory.key?("amounts") # a reflexive trigger's
70
+ frame["ctx"].merge!(Snapshot.deep_copy(obj.memory["departed"])) if obj.memory.key?("departed") # and its LKI
70
71
  end
71
72
 
72
73
  # CR 400.7j / 608.2c: objects an effect moves or creates, so later effects can refer to them
@@ -17,6 +17,7 @@ module Enjine
17
17
  current = chars(obj)
18
18
  { "controller" => obj.controller, "card" => obj.card, "types" => current.types, "subtypes" => current.subtypes,
19
19
  "power" => current.power, "toughness" => current.toughness, "counters" => obj.counters.dup,
20
+ "mana_value" => Mechanics::Amount.mana_value(self, obj), # CR 202.3
20
21
  "keywords" => current.keywords.map(&:to_s), # deathtouch, lifelink, wither (CR 702.2e, 702.15c, 702.80b)
21
22
  "token" => token?(obj), "except" => copy_exceptions(obj), "abilities" => current.abilities }
22
23
  .merge(obj.memory.slice(Mechanics::Linked::EXILED)) # the cards exiled with it (CR 607.2a)
@@ -109,7 +109,8 @@ module Enjine
109
109
  # The part of last known information an ability keeps in its memory ("source_lki"): plain data.
110
110
  def last_known_memory(lki)
111
111
  info = { "controller" => lki["controller"], "power" => lki["power"], "toughness" => lki["toughness"],
112
- "counters" => lki["counters"].dup, "card" => lki["card"], "keywords" => lki["keywords"].dup }
112
+ "mana_value" => lki["mana_value"], "counters" => lki["counters"].dup, "card" => lki["card"],
113
+ "keywords" => lki["keywords"].dup }
113
114
  info["except"] = lki["except"] if lki["except"] # what a copy of it copies (CR 707.2, Engine::Copies)
114
115
  info.merge(lki.slice(Mechanics::Linked::EXILED).transform_values(&:dup)) # CR 607.2a (Linked.exiled_with)
115
116
  end
@@ -117,6 +118,29 @@ module Enjine
117
118
  # What the resolving spell or ability remembers about an object that left the battlefield during it.
118
119
  def resolving_last_known(id) = state.frames.rfind { it["type"] == "resolve" && it["ctx"] }&.dig("ctx", "lki", id)
119
120
 
121
+ # A permanent that left the battlefield while a spell or ability resolves becomes a new object (CR 400.7);
122
+ # the resolving one links the new object to the permanent it was, so "the sacrificed creature's power"
123
+ # ({"power": {"result": n}}) is read as the permanent last existed (CR 608.2h).
124
+ def remember_departed!(new_id, old_id)
125
+ resolving = state.frames.rfind { it["type"] == "resolve" && it["ctx"] } or return
126
+ (resolving["ctx"]["departed"] ||= {})[new_id] = old_id
127
+ end
128
+
129
+ # What the resolving spell or ability knows of permanents that left (their last known information and the
130
+ # new objects they became), for a reflexive trigger it creates ("when you do", CR 603.12, 608.2h).
131
+ def departed_snapshot
132
+ ctx = state.frames.rfind { it["type"] == "resolve" && it["ctx"] }&.fetch("ctx") or return nil
133
+ snapshot = ctx.slice("lki", "departed")
134
+ snapshot.empty? ? nil : Snapshot.deep_copy(snapshot)
135
+ end
136
+
137
+ # The last known information of the permanent a new object was, if it left the battlefield during the
138
+ # resolving spell or ability.
139
+ def departed_last_known(new_id)
140
+ ctx = state.frames.rfind { it["type"] == "resolve" && it["ctx"] }&.fetch("ctx") or return nil
141
+ (old_id = ctx.dig("departed", new_id)) && ctx.dig("lki", old_id)
142
+ end
143
+
120
144
  # CR 702.171b: a permanent becomes saddled until the end of the turn; leaving the battlefield ends it (the
121
145
  # card is a new object, CR 400.7), and it isn't copiable (it lives on the object, not its characteristics).
122
146
  def saddle!(id) = object_memory!(id, "saddled")
@@ -120,10 +120,7 @@ module Enjine
120
120
  face_down = to == :exile ? data["face_down"] : nil
121
121
  fresh = create_object(card_after_move(old, to), old.owner, to, hidden: !face_down.nil?) # front face up (Engine::Faces)
122
122
  carry_copy_status!(fresh, old, to) # a token or copy stays one (Engine::Copies)
123
- if old.zone == :battlefield
124
- leave_combat!(id)
125
- end_while_source_effects!(id) # CR 611.2b
126
- end
123
+ left_battlefield!(fresh, old) if lki
127
124
  arrive!(fresh, old, data)
128
125
  fresh.memory.merge!("face_down" => true, "may_look" => face_down.uniq.sort) if face_down
129
126
  list = state.zone_list(to, old.owner)
@@ -140,6 +137,14 @@ module Enjine
140
137
  fresh
141
138
  end
142
139
 
140
+ # A permanent that left the battlefield: the resolving spell or ability links the new object to it (CR 400.7,
141
+ # 608.2h; Engine::TurnHistory), it leaves combat, and effects lasting while it stays end (CR 611.2b).
142
+ def left_battlefield!(fresh, old)
143
+ remember_departed!(fresh.id, old.id)
144
+ leave_combat!(old.id)
145
+ end_while_source_effects!(old.id)
146
+ end
147
+
143
148
  # Status of the new object in its new zone (CR 110.2, 614.12 entering status).
144
149
  def arrive!(fresh, old, data)
145
150
  case data["to"]
@@ -225,8 +230,13 @@ module Enjine
225
230
  end
226
231
  end
227
232
 
228
- def shuffle_library!(index)
229
- state.rng.shuffle!(player(index).library)
233
+ # `then_on_top`: cards set aside while the rest is shuffled, then put on top in that order ("then shuffle and
234
+ # put that card on top"; they stay in the library, CR 701.24a).
235
+ def shuffle_library!(index, then_on_top: [])
236
+ library = player(index).library
237
+ library.reject! { then_on_top.include?(it) }
238
+ state.rng.shuffle!(library)
239
+ library.unshift(*then_on_top)
230
240
  emit Events::LibraryShuffled.new(player: name(index))
231
241
  end
232
242