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
@@ -12,24 +12,29 @@ module Enjine
12
12
 
13
13
  primitive :ability, :additional_triggers,
14
14
  description: "While this permanent is on the battlefield, a triggered ability of a permanent its " \
15
- "controller controls that triggers because of `cause` triggers an additional time. " \
16
- "`cause`: you_gain_life (its controller gaining life).",
17
- rules: %w[603.2d 119.9],
18
- params: { "cause" => CAUSES.join(" | ") },
19
- example: { "additional_triggers" => { "cause" => "you_gain_life" } }
15
+ "controller controls triggers an additional time if it triggers because of `cause` " \
16
+ "(you_gain_life: its controller gaining life) and/or its permanent matches `of` (a " \
17
+ "filter: \"triggered abilities of Allies you control\", {\"creature\": {\"other\": " \
18
+ "true, \"subtypes\": \"chosen\"}}). At least one of them.",
19
+ rules: %w[603.2d 119.9 607.2d],
20
+ params: { "cause" => CAUSES.join(" | "), "of" => "filter of the permanent whose ability triggers" },
21
+ example: { "additional_triggers" => { "of" => { "creature" => { "subtypes" => ["ally"] } } } }
20
22
 
21
- attr_reader :cause
23
+ attr_reader :cause, :of
22
24
 
23
25
  def self.build(params, errors, path, **)
24
- Mechanics.check_keys(params, %w[cause], errors, path)
26
+ Mechanics.check_keys(params, %w[cause of], errors, path)
25
27
  cause = Mechanics.fetch_param(params, :cause)
26
- errors << "#{path}.cause: must be one of #{CAUSES.join(", ")}" unless CAUSES.include?(cause)
27
- new(cause)
28
+ errors << "#{path}.cause: must be one of #{CAUSES.join(", ")}" unless cause.nil? || CAUSES.include?(cause)
29
+ raw = Mechanics.fetch_param(params, :of)
30
+ errors << "#{path}: needs cause or of" if cause.nil? && raw.nil?
31
+ new(cause, raw && Mechanics.build_entry(:filter, raw, errors, "#{path}.of"))
28
32
  end
29
33
 
30
- def initialize(cause)
34
+ def initialize(cause, filter = nil)
31
35
  super()
32
36
  @cause = cause
37
+ @of = filter
33
38
  freeze
34
39
  end
35
40
 
@@ -37,11 +42,18 @@ module Enjine
37
42
  def mana_ability? = false
38
43
  def allowed_types = nil
39
44
  def try_effects = []
40
- def to_s = "triggered abilities of your permanents that trigger from you gaining life trigger an additional time"
45
+
46
+ def to_s
47
+ label = of.is_a?(Class) ? of.primitive_name.to_s.tr("_", " ") : of.to_s
48
+ whose = of ? "each #{label} you control" : "your permanents"
49
+ "triggered abilities of #{whose}#{" that trigger from you gaining life" if cause} trigger an additional time"
50
+ end
41
51
 
42
52
  # `holder`: the permanent with this ability; `source`: the triggering ability's source (a permanent).
43
53
  def additional_trigger?(engine, event, holder, source)
44
54
  return false unless source["controller"] == holder.controller
55
+ return false if of && !of.match_for?(engine, source["id"], holder.controller, holder.id)
56
+ return true if cause.nil?
45
57
 
46
58
  event.is_a?(Events::LifeChanged) && event.gain.positive? && engine.names.index(event.player) == holder.controller
47
59
  end
@@ -73,14 +73,17 @@ module Enjine
73
73
  class CostModifier < Primitive
74
74
  TYPES = %w[artifact creature enchantment land planeswalker instant sorcery].freeze
75
75
  CASTERS = %w[you opponent any].freeze
76
+ SPEC_KEYS = %w[subtypes colors].freeze
76
77
 
77
78
  primitive :ability, :cost_modifier,
78
79
  description: "While on the battlefield: spells cast by `caster` (you, opponent or any; default you) " \
79
80
  "whose card types match `spells` ({\"any_of\": [types], \"not\": [types]}, default all " \
80
- "spells) cost `reduce` less or `increase` more generic mana. `from` / `not_from`: only " \
81
+ "spells; `subtypes` and `colors` narrow it: \"Dragon spells\", \"red spells\") cost " \
82
+ "`reduce` less or `increase` more generic mana. `from` / `not_from`: only " \
81
83
  "spells cast from (not from) that zone (hand | graveyard | exile | library | outside).",
82
84
  rules: %w[601.2f 118.7a 604.1 601.2a],
83
- params: { "spells" => "{\"any_of\": [types], \"not\": [types]} (optional)",
85
+ params: { "spells" => "{\"any_of\": [types], \"not\": [types], \"subtypes\": [subtypes], \"colors\": [colors]} " \
86
+ "(optional)",
84
87
  "caster" => "you | opponent | any", "reduce" => "amount", "increase" => "amount",
85
88
  "from" => "zone", "not_from" => "zone" },
86
89
  example: { "cost_modifier" => { "spells" => { "not" => ["creature"] }, "reduce" => 1 } }
@@ -94,7 +97,7 @@ module Enjine
94
97
  errors << "#{path}.spells: must be an object"
95
98
  spells = {}
96
99
  end
97
- Mechanics.check_keys(spells, %w[any_of not], errors, "#{path}.spells")
100
+ Mechanics.check_keys(spells, %w[any_of not] + SPEC_KEYS, errors, "#{path}.spells")
98
101
  lists = %w[any_of not].to_h do |key|
99
102
  list = Array(spells.transform_keys(&:to_s)[key]).map(&:to_s)
100
103
  errors << "#{path}.spells.#{key}: must be card types (#{TYPES.join(", ")})" unless (list - TYPES).empty?
@@ -103,7 +106,13 @@ module Enjine
103
106
  caster = Mechanics.fetch_param(params, :caster) || "you"
104
107
  errors << "#{path}.caster: must be one of #{CASTERS.join(", ")}" unless CASTERS.include?(caster)
105
108
  new(caster, lists["any_of"], lists["not"], *build_amount(params, errors, path),
106
- zones: CastFrom.build(params, errors, path))
109
+ zones: CastFrom.build(params, errors, path), spec: build_spec(spells, errors, path))
110
+ end
111
+
112
+ # "Dragon spells", "red spells": the subtypes and colors in `spells`, matched as a card spec.
113
+ def self.build_spec(spells, errors, path)
114
+ data = spells.transform_keys(&:to_s).slice(*SPEC_KEYS)
115
+ data.empty? ? nil : CardSpec.build(data, errors, "#{path}.spells")
107
116
  end
108
117
 
109
118
  def self.build_amount(params, errors, path)
@@ -115,8 +124,9 @@ module Enjine
115
124
  [Amount.build(params[keys.first], errors, "#{path}.#{keys.first}", targets_count: 0), keys.first == "reduce" ? -1 : 1]
116
125
  end
117
126
 
118
- def initialize(caster, any_of, not_types, amount, sign, zones: CastFrom.new(nil, nil))
127
+ def initialize(caster, any_of, not_types, amount, sign, zones: CastFrom.new(nil, nil), spec: nil)
119
128
  super()
129
+ @spec = spec
120
130
  @caster = caster
121
131
  @any_of = any_of.freeze
122
132
  @not_types = not_types.freeze
@@ -135,15 +145,18 @@ module Enjine
135
145
  def change_for(engine, permanent, spell, caster_index)
136
146
  return 0 unless caster == "any" || (caster == "you") == (caster_index == permanent.controller)
137
147
 
138
- types = engine.chars(spell).types
148
+ chars = engine.chars(spell)
149
+ types = chars.types
139
150
  return 0 if types.intersect?(not_types) || !(any_of.empty? || types.intersect?(any_of))
151
+ return 0 if @spec && !@spec.chars_match?(engine, chars)
140
152
  return 0 unless @zones.match?(engine.cast_origin(spell))
141
153
 
142
154
  sign * amount.value(engine, EffectContext.new(permanent.controller, permanent.id, permanent.id, []))
143
155
  end
144
156
 
145
157
  def to_s
146
- kinds = [not_types.map { "non#{it}" }, any_of.join(" or ")].flatten.reject(&:empty?).join(" ")
158
+ narrow = @spec ? [@spec.colors.map(&:to_s), @spec.subtypes.map { it.to_s.capitalize }] : []
159
+ kinds = [not_types.map { "non#{it}" }, narrow, any_of.join(" or ")].flatten.reject(&:empty?).join(" ")
147
160
  whose = { "you" => "you cast", "opponent" => "your opponents cast", "any" => "" }[caster]
148
161
  "#{[kinds, "spells", whose].reject(&:empty?).join(" ")}#{@zones} cost {#{amount}} " \
149
162
  "#{sign.negative? ? "less" : "more"}"
@@ -76,20 +76,22 @@ module Enjine
76
76
  "`amount` mana (default 1) of one color you choose as you activate it " \
77
77
  "(`\"any_one\"`) or in any combination of colors (`\"any\"`); `colors` limits " \
78
78
  "the choice. `\"chosen\"`: of the color chosen as this entered (choose_color). " \
79
- "`spend_only` restricts how the mana can be spent. `only_if`: \"Activate only if ...\".",
79
+ "`spend_only` restricts how the mana can be spent. `only_if`: \"Activate only if ...\". " \
80
+ "`limit: \"once_each_turn\"`: \"Activate only once each turn.\"",
80
81
  rules: %w[605.1a 605.3a 605.3b 106.1a 106.4 106.6 607.2d 602.5b],
81
82
  params: { "cost" => "list of costs (e.g. [\"tap\"])",
82
83
  "produce" => "mana symbols added, e.g. \"{G}\" or \"{C}{C}\"",
83
84
  "amount" => "amount (default 1; with produce: how many times)",
84
85
  "color" => CHOICES.join(" | "), "colors" => "list of colors to choose from (default all five)",
85
86
  "spend_only" => "{\"spells\": card spec, \"abilities\": card spec, \"from\" | \"not_from\": zone}",
86
- "only_if" => "condition: it can be activated only while it holds (CR 602.5b)" },
87
+ "only_if" => "condition: it can be activated only while it holds (CR 602.5b)",
88
+ "limit" => "\"once_each_turn\" (CR 602.5b)" },
87
89
  example: { "mana" => { "cost" => ["tap"], "produce" => "{C}" } }
88
90
 
89
91
  attr_reader :costs, :produce, :color, :colors, :amount, :spend_only
90
92
 
91
93
  def self.build(params, errors, path, **)
92
- Mechanics.check_keys(params, %w[cost produce amount color colors spend_only only_if], errors, path)
94
+ Mechanics.check_keys(params, %w[cost produce amount color colors spend_only only_if limit], errors, path)
93
95
  costs = build_costs(Mechanics.fetch_param(params, :cost), errors, path)
94
96
  raw = Mechanics.fetch_param(params, :produce)
95
97
  color = Mechanics.fetch_param(params, :color)
@@ -103,7 +105,8 @@ module Enjine
103
105
  raw_if = Mechanics.fetch_param(params, :only_if)
104
106
  only_if = raw_if && Condition.build(raw_if, errors, "#{path}.only_if")
105
107
  new(costs.compact.freeze, produce, color: color, colors: build_colors(params, color, errors, path),
106
- amount: amount, spend_only: spend_only, only_if: only_if)
108
+ amount: amount, spend_only: spend_only, only_if: only_if,
109
+ per_turn: build_limit(params, errors, path))
107
110
  end
108
111
 
109
112
  # CR 605.3a: a mana ability is paid for and resolves at once, so none of its costs can wait for a
@@ -126,6 +129,14 @@ module Enjine
126
129
  (cost.respond_to?(:mana_cost) && cost.mana_cost.hybrid.any? { Mana.mono_hybrid?(it) })
127
130
  end
128
131
 
132
+ LIMITS = { "once_each_turn" => 1 }.freeze
133
+
134
+ def self.build_limit(params, errors, path)
135
+ limit = Mechanics.fetch_param(params, :limit)
136
+ errors << "#{path}.limit: must be one of #{LIMITS.keys.join(", ")}" unless limit.nil? || LIMITS.key?(limit)
137
+ LIMITS[limit]
138
+ end
139
+
129
140
  def self.build_colors(params, color, errors, path)
130
141
  raw = Mechanics.fetch_param(params, :colors)
131
142
  return COLORS if raw.nil?
@@ -137,8 +148,9 @@ module Enjine
137
148
  COLORS
138
149
  end
139
150
 
140
- def initialize(costs, produce, color: nil, colors: COLORS, amount: nil, spend_only: nil, only_if: nil)
151
+ def initialize(costs, produce, color: nil, colors: COLORS, amount: nil, spend_only: nil, only_if: nil, per_turn: nil)
141
152
  super()
153
+ @per_turn = per_turn
142
154
  @costs = costs
143
155
  @produce = produce
144
156
  @color = color
@@ -164,6 +176,9 @@ module Enjine
164
176
  costs.all? { |c| c.payable?(engine, object) } && (mana_cost.nil? || engine.pool_covers?(object.controller, mana_cost))
165
177
  end
166
178
 
179
+ # How many times it may be activated each turn (nil: no limit, CR 602.5b); kept by Engine#record_activation!.
180
+ attr_reader :per_turn
181
+
167
182
  # The sum of its mana costs, or nil.
168
183
  attr_reader :mana_cost
169
184
 
@@ -201,7 +216,7 @@ module Enjine
201
216
  "#{" (#{colors.join("/")})" unless colors == COLORS}"
202
217
  end
203
218
  "#{costs.join(", ")}: #{what}.#{" #{spend_only.to_s.capitalize}." if spend_only}" \
204
- "#{" Activate only if #{@only_if}." if @only_if}"
219
+ "#{" Activate only if #{@only_if}." if @only_if}#{" Activate only once each turn." if per_turn}"
205
220
  end
206
221
  end
207
222
  end
@@ -350,8 +350,8 @@ module Enjine
350
350
 
351
351
  own = costs.filter_map { it.zone if it.respond_to?(:zone) }.first
352
352
  errors << "#{path}.zone: its costs are paid from the #{own}" if own && own.to_s != zone
353
- if costs.any? { it.is_a?(Costs::Tap) || it.is_a?(Costs::SacrificeSelf) }
354
- errors << "#{path}.zone: an ability of a card in the #{zone} can't tap or sacrifice it"
353
+ if costs.any? { it.is_a?(Costs::Tap) || Costs::LEAVING_SELF.include?(it.class) }
354
+ errors << "#{path}.zone: an ability of a card in the #{zone} can't tap, sacrifice or exile it"
355
355
  end
356
356
  zone.to_sym
357
357
  end
@@ -386,9 +386,9 @@ module Enjine
386
386
  def self.check_cost_zones(costs, errors, path)
387
387
  zones = costs.filter_map { it.zone if it.respond_to?(:zone) }.uniq
388
388
  errors << "#{path}.cost: costs can't be paid from both #{zones.join(" and ")}" if zones.size > 1
389
- return if zones.empty? || costs.none? { it.is_a?(Costs::Tap) || it.is_a?(Costs::SacrificeSelf) }
389
+ return if zones.empty? || costs.none? { it.is_a?(Costs::Tap) || Costs::LEAVING_SELF.include?(it.class) }
390
390
 
391
- errors << "#{path}.cost: a cost paid from the #{zones.first} can't be combined with tap or sacrifice"
391
+ errors << "#{path}.cost: a cost paid from the #{zones.first} can't be combined with tap, sacrifice or exile"
392
392
  end
393
393
 
394
394
  attr_reader :cost_reduction
@@ -513,7 +513,8 @@ module Enjine
513
513
  "and is paid on activation. Sorcery speed, and only one loyalty ability per " \
514
514
  "planeswalker per turn.",
515
515
  rules: %w[606.3 606.4 606.5 602.2b],
516
- params: { "cost" => "integer, e.g. 1 for +1 or -2", "targets" => "list of target filters",
516
+ params: { "cost" => "integer, e.g. 1 for +1 or -2; or \"-X\" (X is announced, at most its loyalty)",
517
+ "targets" => "list of target filters",
517
518
  "effects" => "list of effects" },
518
519
  example: { "loyalty" => { "cost" => -3, "targets" => ["creature"],
519
520
  "effects" => [{ "deal_damage" => { "amount" => 3, "to" => { "target" => 0 } } }] } }
@@ -521,8 +522,8 @@ module Enjine
521
522
  def self.build(params, errors, path, **)
522
523
  Mechanics.check_keys(params, %w[cost] + BODY_KEYS, errors, path)
523
524
  cost = Mechanics.fetch_param(params, :cost)
524
- errors << "#{path}.cost: must be an integer loyalty change" unless cost.is_a?(Integer)
525
- new(**parse_body(params, errors, path), loyalty: cost.to_i)
525
+ errors << "#{path}.cost: must be an integer loyalty change or \"-X\"" unless cost.is_a?(Integer) || cost == "-X"
526
+ new(**parse_body(params, errors, path), loyalty: cost == "-X" ? cost : cost.to_i)
526
527
  end
527
528
 
528
529
  def initialize(loyalty:, **)
@@ -531,11 +532,14 @@ module Enjine
531
532
  end
532
533
 
533
534
  def kind = :loyalty
534
- def loyalty_cost = @loyalty
535
+ def loyalty? = true
536
+ # The fixed loyalty change; a "-X" ability's is chosen as it's activated (CR 107.3, 601.2b), so it's 0 here.
537
+ def loyalty_cost = x_loyalty? ? 0 : @loyalty
538
+ def x_loyalty? = @loyalty == "-X"
535
539
  def sorcery_speed? = true
536
540
  def allowed_types = [:planeswalker]
537
541
 
538
- def to_s = "#{"+" if @loyalty.positive?}#{@loyalty}: #{effects_text}"
542
+ def to_s = "#{"+" if !x_loyalty? && @loyalty.positive?}#{@loyalty}: #{effects_text}"
539
543
  end
540
544
  end
541
545
  end
@@ -42,7 +42,7 @@ module Enjine
42
42
  example: { "static" => { "affects" => { "each" => "creature", "controller" => "you" },
43
43
  "pump" => { "power" => 1, "toughness" => 1 } } }
44
44
 
45
- GRANTABLE = %i[triggered mana activated hexproof_from protection_from tapping_power].freeze
45
+ GRANTABLE = %i[triggered mana activated loyalty hexproof_from protection_from tapping_power].freeze
46
46
  # Data that would make the layer pass depend on its own result (CR 613.8).
47
47
  LIVE = %w[power toughness greatest_power attacking blocking tapped].freeze
48
48
  KEYS = %w[affects if pump keywords creature_types abilities types subtypes loses_abilities colors
@@ -272,17 +272,22 @@ module Enjine
272
272
  module Effects
273
273
  # "gains flying until end of turn" (CR 611.2, 613.1f): grant keywords to a locked set.
274
274
  class Grant < Effect
275
+ DURATIONS = %w[end_of_turn until_your_next_turn].freeze
276
+
275
277
  primitive :effect, :grant,
276
- description: "Each referenced permanent (or spell on the stack) gains the `keywords` until end of turn.",
277
- rules: %w[611.2a 611.2c 613.1f 514.2],
278
- params: { "keywords" => "list of keywords", "to" => "selector" },
278
+ description: "Each referenced permanent (or spell on the stack) gains the `keywords` until end of turn " \
279
+ "(`duration: \"until_your_next_turn\"`: until its controller's next turn begins).",
280
+ rules: %w[611.2a 611.2c 613.1f 514.2 500.1],
281
+ params: { "keywords" => "list of keywords", "to" => "selector", "duration" => DURATIONS.join(" | ") },
279
282
  example: { "grant" => { "keywords" => ["flying"], "to" => { "target" => 0 } } }
280
283
 
281
284
  attr_reader :keywords
282
285
 
283
- def self.keys = %w[keywords to if]
286
+ def self.keys = %w[keywords to duration if]
284
287
 
285
288
  def setup(params, errors, path)
289
+ @duration = Mechanics.fetch_param(params, :duration) || "end_of_turn"
290
+ errors << "#{path}.duration: must be one of #{DURATIONS.join(", ")}" unless DURATIONS.include?(@duration)
286
291
  @keywords = Array(Mechanics.fetch_param(params, :keywords)).map { it.to_s.downcase.tr(" -", "__") }
287
292
  errors << "#{path}.keywords: needs at least one keyword" if @keywords.empty?
288
293
  @keywords.each { errors << "#{path}.keywords: unknown keyword #{it.inspect}" unless Enjine.registry.fetch(:keyword, it) }
@@ -291,10 +296,11 @@ module Enjine
291
296
  # A spell on the stack can gain keywords too ("those spells gain wither", Engine::SpellGrants).
292
297
  def apply(engine, ctx)
293
298
  spells = refs(engine, ctx).select { (obj = engine.state.objects[it]) && obj.zone == :stack && !obj.ability? }
294
- engine.add_continuous_effect!("grant", ctx, objects(engine, ctx) + spells, "keywords" => keywords)
299
+ engine.add_continuous_effect!("grant", ctx, objects(engine, ctx) + spells, "keywords" => keywords,
300
+ "duration" => @duration)
295
301
  end
296
302
 
297
- def to_s = "gains #{keywords.join(", ")} until end of turn → #{to}"
303
+ def to_s = "gains #{keywords.join(", ")} until #{@duration.delete_prefix("until_").tr("_", " ")} → #{to}"
298
304
  end
299
305
 
300
306
  # Setting a creature's creature types until end of turn; its other creature types are lost (CR 205.1a, 613.1d).
@@ -25,6 +25,31 @@ module Enjine
25
25
  def to_s = "Sacrifice this"
26
26
  end
27
27
 
28
+ # "Exile this permanent" as a cost (CR 406.1, 602.2b): it leaves the battlefield for exile. Unlike a
29
+ # sacrifice, nothing that watches sacrifices sees it.
30
+ class ExileSelf < Primitive
31
+ primitive :cost, :exile_self,
32
+ description: "Exile the permanent the ability belongs to (\"Exile this enchantment: ...\").",
33
+ rules: %w[406.1 602.2b 118.3],
34
+ example: "exile_self"
35
+
36
+ INSTANCE = new.freeze
37
+
38
+ def self.build(params, errors, path, **)
39
+ Mechanics.check_keys(params, [], errors, path)
40
+ INSTANCE
41
+ end
42
+
43
+ def mana? = false
44
+ def uses_source? = true
45
+ def payable?(engine, object) = engine.state.battlefield.include?(object.id)
46
+ def pay!(engine, object) = engine.move!(object.id, :exile, reason: "cost")
47
+ def to_s = "Exile this"
48
+ end
49
+
50
+ # The costs that take the permanent itself off the battlefield (CR 113.6m: not from another zone).
51
+ LEAVING_SELF = [SacrificeSelf, ExileSelf].freeze
52
+
28
53
  # "Sacrifice a creature" / "Sacrifice another creature or artifact" as a cost (CR 701.21a, 601.2h).
29
54
  # The permanent is chosen while casting and sacrificed as the spell or ability becomes cast; what
30
55
  # it was (types, subtypes, power) is kept for "if you sacrificed a Klingon this way" (CR 608.2h).
@@ -33,7 +58,7 @@ module Enjine
33
58
  description: "Sacrifice `count` permanents you control matching `filter` (`other: true`: not the " \
34
59
  "source). Chosen while casting or activating, sacrificed as it becomes cast; the " \
35
60
  "sacrificed permanent's last known types and subtypes are remembered (condition " \
36
- "`sacrificed`).",
61
+ "`sacrificed`), and its power, toughness and mana value (amount `sacrificed`).",
37
62
  rules: %w[701.21a 601.2h 602.2b 118.3 608.2h],
38
63
  params: { "filter" => "filter (default permanent)", "count" => "integer (default 1)",
39
64
  "other" => "true: not the source itself" },
@@ -44,7 +69,8 @@ module Enjine
44
69
  perm = engine.object(entry["object"])
45
70
  facts = engine.chars(perm)
46
71
  entry.merge!("types" => facts.types.map(&:to_s), "subtypes" => facts.subtypes.map(&:to_s),
47
- "power" => engine.power(perm))
72
+ "power" => engine.power(perm), "toughness" => engine.toughness(perm),
73
+ "mana_value" => Amount.mana_value(engine, perm))
48
74
  engine.move!(perm.id, :graveyard, reason: "sacrifice")
49
75
  end
50
76
  end
@@ -12,7 +12,8 @@ module Enjine
12
12
  # on the battlefield. Granted abilities are part of this card's data, referenced "<this effect's ref>:<j>"
13
13
  # (CardDefinition#effect_granted); on the permanent they are "granted:<card>:<ref>:<j>".
14
14
  class Become < Effect
15
- DURATIONS = %w[end_of_turn indefinite while_source_on_battlefield while_you_control_source].freeze
15
+ DURATIONS = %w[end_of_turn until_your_next_turn indefinite while_source_on_battlefield
16
+ while_you_control_source].freeze
16
17
  GRANTABLE = %i[triggered mana activated play_from additional_land_plays hexproof_from protection_from].freeze
17
18
  # Card types an effect can add (CR 205.1b); instants, sorceries and planeswalkers aren't permanents it makes.
18
19
  ADDABLE_TYPES = %w[artifact creature enchantment land].freeze
@@ -21,7 +22,8 @@ module Enjine
21
22
 
22
23
  primitive :effect, :become,
23
24
  description: "Each referenced permanent becomes what the parameters say, for `duration` " \
24
- "(end_of_turn, default; indefinite: as long as it stays on the battlefield; " \
25
+ "(end_of_turn, default; until_your_next_turn: until your next turn begins; indefinite: " \
26
+ "as long as it stays on the battlefield; " \
25
27
  "while_source_on_battlefield: for as long as this effect's source remains on the " \
26
28
  "battlefield; while_you_control_source: for as long as you control it): it has " \
27
29
  "the card `types`, `supertypes` and `subtypes` in addition to its other types (layer 4, " \
@@ -52,6 +54,8 @@ module Enjine
52
54
  attr_reader :ref, :creature_types, :base_power_toughness, :granted_abilities, :duration, :card_types,
53
55
  :supertypes, :subtypes, :colors, :keywords
54
56
 
57
+ UNTIL = { "end_of_turn" => " until end of turn", "until_your_next_turn" => " until your next turn" }.freeze
58
+
55
59
  def self.keys = CHANGES + %w[duration to if]
56
60
 
57
61
  # A list of registered names (card types, supertypes, keywords) as symbols; [] when absent.
@@ -96,7 +100,7 @@ module Enjine
96
100
  def duration_details(engine, ctx)
97
101
  case @duration
98
102
  when "indefinite" then { "duration" => "permanent" }
99
- when "end_of_turn" then { "duration" => "end_of_turn" }
103
+ when "end_of_turn", "until_your_next_turn" then { "duration" => @duration } # CR 611.2a, 500.1
100
104
  else
101
105
  holds = engine.source_duration_holds?(@duration, ctx.source, ctx.controller)
102
106
  holds ? { "duration" => @duration, "source" => ctx.source } : nil
@@ -132,7 +136,7 @@ module Enjine
132
136
  bits << "base #{base_power_toughness.values.join("/")}" if base_power_toughness
133
137
  bits << keywords.join(", ") unless keywords.empty?
134
138
  bits << "\"#{granted_abilities.join("; ")}\"" unless granted_abilities.empty?
135
- "becomes #{bits.join(", ")}#{" until end of turn" if duration == "end_of_turn"} → #{to}"
139
+ "becomes #{bits.join(", ")}#{UNTIL[duration]} → #{to}"
136
140
  end
137
141
 
138
142
  def self.parse_colors(raw, errors, path)
@@ -10,7 +10,7 @@ module Enjine
10
10
  class ChooseCards < ChoiceEffect
11
11
  include NestedList
12
12
 
13
- PUT = %w[hand graveyard exile battlefield library_top library_bottom].freeze
13
+ PUT = %w[hand graveyard exile battlefield library_top library_bottom discard].freeze
14
14
  ZONES = %w[hand graveyard].freeze
15
15
  REVEAL = %w[chosen all].freeze
16
16
  LISTS = %w[then otherwise].freeze
@@ -19,12 +19,12 @@ module Enjine
19
19
  description: "You choose `count` (default 1; \"any\": any number) cards matching `for` from `from`: a " \
20
20
  "selector (e.g. {\"result\": 0}, the cards an earlier effect milled) or a player's zone, " \
21
21
  "{\"hand\": selector} / {\"graveyard\": selector}. `optional`: you may choose fewer. Then " \
22
- "they are put into `put` (`tapped` onto the battlefield, under your control; exiled cards " \
23
- "`link`ed to the source). `look`: you look at the whole zone first; `reveal`: \"chosen\" " \
24
- "reveals what you chose, \"all\" the whole set. `then` runs if a card was chosen, " \
25
- "`otherwise` if none was (\"if you can't\"). `random: true`: the cards are picked at random " \
26
- "(the game's random number generator) instead of chosen.",
27
- rules: %w[701.20a 701.20e 400.7j 608.2c 607.2a 118.12 701.9b],
22
+ "they are put into `put` (`discard`: from a hand, its owner discards them; `tapped` onto the " \
23
+ "battlefield, under your control; exiled cards `link`ed to the source). `look`: you look at the " \
24
+ "whole zone first; `reveal`: \"chosen\" reveals what you chose, \"all\" the whole set. " \
25
+ "`then` runs if a card was chosen, `otherwise` if none was (\"if you can't\"). `random: true`: the " \
26
+ "cards are picked at random (the game's random number generator) instead of chosen.",
27
+ rules: %w[701.20a 701.20e 400.7j 608.2c 607.2a 118.12 701.9a 701.9b],
28
28
  params: { "from" => "selector or {\"hand\"|\"graveyard\": player selector}", "for" => "card spec",
29
29
  "count" => "integer or \"any\"", "optional" => "boolean", "put" => PUT.join(" | "),
30
30
  "tapped" => "boolean", "link" => "boolean", "look" => "boolean", "random" => "boolean",
@@ -47,6 +47,7 @@ module Enjine
47
47
  @optional = Mechanics.flag_param(params, "optional", errors, path)
48
48
  @put = Mechanics.fetch_param(params, :put).to_s
49
49
  errors << "#{path}.put: must be one of #{PUT.join(", ")}" unless PUT.include?(@put)
50
+ errors << "#{path}.put: discard takes cards from a hand" if @put == "discard" && @from_zone != "hand"
50
51
  @flags = %w[tapped link look random].to_h { [it, Mechanics.flag_param(params, it, errors, path)] }
51
52
  errors << "#{path}.count: must be a number to pick at random" if @flags["random"] && @count == "any"
52
53
  @reveal = Mechanics.fetch_param(params, :reveal)
@@ -154,6 +155,7 @@ module Enjine
154
155
 
155
156
  def move(engine, ctx, id)
156
157
  case put
158
+ when "discard" then engine.move!(id, :graveyard, reason: "discard") # CR 701.9a: the owner discards it
157
159
  when "library_top", "library_bottom"
158
160
  engine.move!(id, :library, reason: "put", position: put == "library_top" ? :top : :bottom)
159
161
  else
@@ -104,7 +104,8 @@ module Enjine
104
104
  source = { "id" => ctx.source, "card" => card, "controller" => ctx.controller, "ability" => ref }
105
105
  engine.queue_pending_trigger!(source, nil, { "results" => Snapshot.deep_copy(ctx.results || []),
106
106
  "amounts" => (ctx.amounts || []).dup,
107
- "triggering" => memory["triggering"]&.dup }.compact)
107
+ "triggering" => memory["triggering"]&.dup,
108
+ "departed" => engine.departed_snapshot }.compact)
108
109
  end
109
110
  end
110
111
  end
@@ -6,7 +6,8 @@ module Enjine
6
6
  module Mechanics
7
7
  module Effects
8
8
  # Search your library (CR 701.23), optionally reveal what was found (CR 701.20a), put it
9
- # into your hand or onto the battlefield, then shuffle (CR 701.24).
9
+ # into your hand, onto the battlefield or into your graveyard, then shuffle (CR 701.24); or shuffle
10
+ # and put the found card on top ("then shuffle and put that card on top").
10
11
  #
11
12
  # The searcher picks one card at a time. Copies of the same card in a library are
12
13
  # interchangeable, so each distinct card is offered once. Only the searcher sees the
@@ -17,7 +18,9 @@ module Enjine
17
18
 
18
19
  primitive :effect, :search_library,
19
20
  description: "Search your library for up to `count` cards matching `for`, then put them into your " \
20
- "hand (or onto the battlefield, tapped if `tapped`, with `counters`) and shuffle. With a " \
21
+ "hand (or onto the battlefield, tapped if `tapped`, with `counters`; or into your " \
22
+ "graveyard) and shuffle. `put: library_top`: shuffle, then put the found card (one) on top. " \
23
+ "Computed bounds in `for` are worked out as the effect resolves. With a " \
21
24
  "stated quality you may find fewer (or none); a search for just \"a card\" must find one " \
22
25
  "if it can. `reveal` shows the found cards to every player. `searcher` names another " \
23
26
  "player who searches their own library (e.g. {\"controller_of\": {\"target\": 0}}); " \
@@ -27,7 +30,7 @@ module Enjine
27
30
  params: { "for" => "card spec: \"card\" (default), {\"types\"|\"subtypes\"|\"supertypes\": [...], " \
28
31
  "\"name\": \"...\"} or {\"any_of\": [spec, ...]}",
29
32
  "count" => "integer (default 1), \"any\" (any number), \"x\" or an amount (\"up to that many\")",
30
- "put" => "\"hand\" (default) or \"battlefield\"",
33
+ "put" => "\"hand\" (default), \"battlefield\", \"graveyard\" or \"library_top\" (count 1)",
31
34
  "tapped" => "true: the cards enter tapped (with put: battlefield)",
32
35
  "counters" => "{counter name: n}: the cards enter with these counters (with put: battlefield)",
33
36
  "reveal" => "true: reveal the found cards",
@@ -37,7 +40,7 @@ module Enjine
37
40
  example: { "search_library" => { "for" => { "supertypes" => ["basic"], "types" => ["land"] },
38
41
  "reveal" => true } }
39
42
 
40
- ZONES = %w[hand battlefield].freeze
43
+ ZONES = %w[hand battlefield graveyard library_top].freeze
41
44
 
42
45
  attr_reader :spec, :count, :put, :tapped, :reveal
43
46
 
@@ -54,6 +57,7 @@ module Enjine
54
57
  @may = Mechanics.flag_param(params, :may, errors, path)
55
58
  @different_names = Mechanics.flag_param(params, :different_names, errors, path)
56
59
  errors << "#{path}.tapped: only cards put onto the battlefield can enter tapped" if @tapped && @put != "battlefield"
60
+ errors << "#{path}.count: put library_top takes one card" if @put == "library_top" && @count != 1
57
61
  @counters = parse_counters(Mechanics.fetch_param(params, :counters), errors, path)
58
62
  raw = Mechanics.fetch_param(params, :searcher) || "you"
59
63
  @searcher = Selector.build(raw, errors, "#{path}.searcher", targets_count: @targets_count, results_count: @results_count)
@@ -101,7 +105,7 @@ module Enjine
101
105
  end
102
106
  found = answers(locals).select { it["type"] == "find" }.map { it["object"] }
103
107
  unless stop?(engine, ctx, locals, found)
104
- choices = candidates(engine, index, found)
108
+ choices = candidates(engine, ctx, index, found)
105
109
  return ask(engine, locals, index, "Search your library (#{spec}): find a card", choices) unless choices.empty?
106
110
  end
107
111
  return :wait if entering_choices?(engine, index, locals, found)
@@ -127,10 +131,11 @@ module Enjine
127
131
 
128
132
  # One option per distinct matching card (CR 701.23a); "done" stops looking unless the
129
133
  # search is for just a quantity of cards (CR 701.23b, 701.23d).
130
- def candidates(engine, index, found)
134
+ # Computed bounds ("mana value equal to 1 plus the sacrificed artifact's") are worked out now (CR 608.2h).
135
+ def candidates(engine, ctx, index, found)
131
136
  names = @different_names ? found.map { engine.chars(it).name } : []
132
137
  ids = (engine.player(index).library - found).sort.select do
133
- spec.match?(engine, engine.object(it)) && !names.include?(engine.chars(it).name)
138
+ spec.match?(engine, engine.object(it), ctx) && !names.include?(engine.chars(it).name)
134
139
  end
135
140
  choices = ids.uniq { engine.object(it).card }.map { { "type" => "find", "object" => it } }
136
141
  return choices if choices.empty? || (spec.any? && count != "any")
@@ -139,11 +144,13 @@ module Enjine
139
144
  end
140
145
 
141
146
  def finish!(engine, index, found, locals)
142
- if reveal && put == "hand"
147
+ if reveal && %w[hand library_top].include?(put)
143
148
  found.each do |id|
144
149
  engine.emit Events::CardRevealed.new(player: engine.name(index), object: id, card: engine.object(id).card)
145
150
  end
146
151
  end
152
+ return engine.shuffle_library!(index, then_on_top: found) if put == "library_top" # CR 701.24a
153
+
147
154
  enters = {}
148
155
  enters["tapped"] = true if tapped
149
156
  enters["counters"] = @counters.dup unless @counters.empty?