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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -1
- data/SUPPORTED.md +12 -9
- data/docs/MECHANICS_COVERAGE.md +1 -1
- data/docs/guides/card-data-format.md +23 -4
- data/docs/plans/dsl-gaps.md +606 -0
- data/docs/plans/standard.md +3 -0
- data/docs/reference/abilities.md +33 -7
- data/docs/reference/costs.md +11 -1
- data/docs/reference/decisions.md +1 -1
- data/docs/reference/effects.md +30 -9
- data/docs/reference/triggers.md +3 -2
- data/lib/enjine/actions/special_action.rb +4 -2
- data/lib/enjine/engine/casting/alt_casting.rb +18 -2
- data/lib/enjine/engine/casting/casting.rb +3 -3
- data/lib/enjine/engine/casting/costs.rb +5 -1
- data/lib/enjine/engine/casting/permissions.rb +1 -1
- data/lib/enjine/engine/characteristics.rb +0 -13
- data/lib/enjine/engine/defined_power_toughness.rb +30 -0
- data/lib/enjine/engine/mana_sources.rb +8 -1
- data/lib/enjine/engine/resolution.rb +1 -0
- data/lib/enjine/engine/triggers.rb +1 -0
- data/lib/enjine/engine/turn_history.rb +25 -1
- data/lib/enjine/engine/zones.rb +16 -6
- data/lib/enjine/mechanics/abilities/additional_triggers.rb +23 -11
- data/lib/enjine/mechanics/abilities/cost_changes.rb +20 -7
- data/lib/enjine/mechanics/abilities/mana_ability.rb +21 -6
- data/lib/enjine/mechanics/abilities/stack_abilities.rb +13 -9
- data/lib/enjine/mechanics/abilities/static.rb +13 -7
- data/lib/enjine/mechanics/costs/sacrifice.rb +28 -2
- data/lib/enjine/mechanics/effects/become.rb +8 -4
- data/lib/enjine/mechanics/effects/choose_cards.rb +9 -7
- data/lib/enjine/mechanics/effects/delayed_triggers.rb +2 -1
- data/lib/enjine/mechanics/effects/search.rb +15 -8
- data/lib/enjine/mechanics/keywords/plot.rb +79 -0
- data/lib/enjine/mechanics/triggers/casting.rb +8 -6
- data/lib/enjine/mechanics/values.rb +82 -14
- data/lib/enjine/version.rb +1 -1
- 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
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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]}
|
|
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
|
-
|
|
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
|
-
|
|
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) ||
|
|
354
|
-
errors << "#{path}.zone: an ability of a card in the #{zone} can't tap or
|
|
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) ||
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
278
|
-
|
|
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
|
|
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
|
|
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;
|
|
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" =>
|
|
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(", ")}#{
|
|
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` (`
|
|
23
|
-
"`link`ed to the source). `look`: you look at the
|
|
24
|
-
"reveals what you chose, \"all\" the whole set.
|
|
25
|
-
"`otherwise` if none was (\"if you can't\"). `random: true`: the
|
|
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
|
|
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
|
|
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
|
|
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 \"
|
|
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
|
-
|
|
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
|
|
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?
|