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
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # "Plot [cost]" (CR 702.170a-b, 116.2k): any time you have priority during your turn, if the stack is empty
7
+ # and it's your main phase, you may pay `cost` and exile this card from your hand (a special action,
8
+ # `{type: "special_action"}`). It becomes a plotted card (Effects::Plot.plot!): its owner may cast it
9
+ # without paying its mana cost during their main phase while the stack is empty, on a later turn
10
+ # (cast option `via: "plotted"`, Engine::AltCasting#plotted_methods).
11
+ class Plot < Primitive
12
+ primitive :ability, :plot,
13
+ description: "Plot `cost`: during your main phase, while the stack is empty, you may pay `cost` and " \
14
+ "exile this card from your hand (special action `{type: \"special_action\"}`). It becomes " \
15
+ "plotted: on a later turn you may cast it from exile without paying its mana cost, " \
16
+ "during your main phase while the stack is empty (cast option `via: \"plotted\"`).",
17
+ rules: %w[702.170a 702.170b 702.170d 116.2k],
18
+ params: { "cost" => "mana cost string" },
19
+ example: { "plot" => { "cost" => "{1}{R}" } }
20
+
21
+ attr_reader :special_cost
22
+
23
+ def self.build(params, errors, path, **)
24
+ Mechanics.check_keys(params, %w[cost], errors, path)
25
+ new(Costs::ManaPayment.build(Mechanics.fetch_param(params, :cost).to_s, errors, "#{path}.cost").mana_cost)
26
+ end
27
+
28
+ def initialize(cost)
29
+ super()
30
+ @special_cost = cost
31
+ freeze
32
+ end
33
+
34
+ def kind = :plot
35
+ def mana_ability? = false
36
+ def allowed_types = nil
37
+ def try_effects = []
38
+ def to_s = "Plot #{special_cost}"
39
+
40
+ # CR 702.170a: during your turn, in your main phase, with an empty stack (as a sorcery, whatever its type).
41
+ def special_allowed?(engine, obj, index) = obj.zone == :hand && engine.sorcery_timing?(index)
42
+
43
+ def special!(engine, obj, _index) = Effects::Plot.plot!(engine, obj.id)
44
+ end
45
+ end
46
+
47
+ module Effects
48
+ # "It becomes plotted" / "plot that card" (CR 702.170c-d): the card is exiled face up (or stays exiled) and
49
+ # becomes a plotted card, remembering the turn. Its owner may cast it without paying its mana cost on a later
50
+ # turn, during their main phase while the stack is empty.
51
+ class Plot < Effect
52
+ primitive :effect, :plot,
53
+ description: "Each referenced card or spell is exiled (if it isn't already) and becomes plotted: its " \
54
+ "owner may cast it from exile without paying its mana cost during their main phase while " \
55
+ "the stack is empty, on any later turn (cast option `via: \"plotted\"`).",
56
+ rules: %w[702.170c 702.170d 406.1],
57
+ params: { "to" => "selector (a spell, a card, or an exiled card such as {\"result\": 0})" },
58
+ example: { "plot" => { "to" => { "target" => 0 } } }
59
+
60
+ def self.keys = %w[to if]
61
+
62
+ def self.plot!(engine, id)
63
+ obj = engine.state.objects[id] or return
64
+ fresh = obj.zone == :exile ? obj : engine.move!(id, :exile, reason: "plot")
65
+ fresh.memory["plotted"] = engine.state.turn if fresh&.zone == :exile
66
+ end
67
+
68
+ def apply(engine, ctx)
69
+ refs(engine, ctx).each do |id|
70
+ obj = engine.state.objects[id]
71
+ Plot.plot!(engine, id) if obj && !obj.ability? && obj.kind == "card"
72
+ end
73
+ end
74
+
75
+ def to_s = "plot → #{to}"
76
+ end
77
+ end
78
+ end
79
+ end
@@ -173,14 +173,16 @@ module Enjine
173
173
  "mana ability. `source`: optional filter on the source's card; `name: \"chosen\"`: only a " \
174
174
  "source with the name chosen as this permanent entered. `exhaust: true`: only an exhaust " \
175
175
  "ability (\"whenever you activate an exhaust ability\", CR 702.177). `boast: true`: only a " \
176
- "boast ability (\"whenever a creature you control boasts\", CR 702.142b).",
177
- rules: %w[602.2 605.1a 603.2 201.4 702.177a 702.142b],
176
+ "boast ability (\"whenever a creature you control boasts\", CR 702.142b). `loyalty: " \
177
+ "true`: only a loyalty ability (CR 606).",
178
+ rules: %w[602.2 605.1a 603.2 201.4 702.177a 702.142b 606.1],
178
179
  params: { "controller" => PLAYERS.join(" | "), "source" => "card filter", "name" => "\"chosen\"",
179
- "exhaust" => "true: only exhaust abilities", "boast" => "true: only boast abilities" },
180
+ "exhaust" => "true: only exhaust abilities", "boast" => "true: only boast abilities",
181
+ "loyalty" => "true: only loyalty abilities" },
180
182
  example: { "activates_ability" => { "controller" => "opponent" } }
181
183
 
182
- def self.keys = %w[controller source name exhaust boast]
183
- KINDS = { "exhaust" => :exhaust?, "boast" => :boast? }.freeze
184
+ def self.keys = %w[controller source name exhaust boast loyalty]
185
+ KINDS = { "exhaust" => :exhaust?, "boast" => :boast?, "loyalty" => :loyalty? }.freeze
184
186
  def event_class = Events::AbilityActivated
185
187
 
186
188
  def setup(params, errors, path)
@@ -202,7 +204,7 @@ module Enjine
202
204
 
203
205
  def to_s
204
206
  what = "#{@kinds.map { "#{it.to_s.delete("?")} " }.join}ability"
205
- "Whenever #{who} activate#{"s" unless @controller == "you"} #{what.start_with?("b") ? "a" : "an"} #{what}#{chosen_words}"
207
+ "Whenever #{who} activate#{"s" unless @controller == "you"} #{what.match?(/\A[aeiou]/) ? "an" : "a"} #{what}#{chosen_words}"
206
208
  end
207
209
 
208
210
  private
@@ -9,10 +9,14 @@ module Enjine
9
9
  # how many the first effect moved or created, e.g. cards drawn)
10
10
  # {"power": selector} / {"toughness": ...} total power / toughness ("Picard's power": {"power": "self"})
11
11
  # {"greatest_power" | "greatest_toughness": selector} the greatest power / toughness among them (0 for none)
12
- # {"hand_size" | "life" | "graveyard_size": player selector}
12
+ # {"hand_size" | "life" | "graveyard_size" | "library_size": player selector}
13
13
  # {"distinct_names": selector} how many different names they have (strange new worlds)
14
14
  # {"mana_value": selector} their total mana value (a spell's X counts on the stack; a spell
15
15
  # countered by the resolving one: as it last existed, CR 608.2h)
16
+ # {"greatest_mana_value": selector} the greatest mana value among them (0 for none, CR 202.3)
17
+ # {"sacrificed": "power" | "toughness" | "mana_value"} of the permanents sacrificed to pay the resolving
18
+ # spell's or ability's cost, as they last existed (CR 601.2h, 608.2h)
19
+ # {"card_types": selector} how many different card types among them (delirium, CR 205.2a)
16
20
  # {"distinct_types": selector} how many different creature types they have (CR 205.3m)
17
21
  # {"distinct_colors": selector} how many colors among them (CR 105.2, 202.2)
18
22
  # {"distinct_land_types": selector} how many basic land types among them (domain, CR 305.6)
@@ -31,14 +35,16 @@ module Enjine
31
35
  # {"mana_spent": {"of": ..., "color" | "colors": ...}} mana spent to cast a spell (CR 601.2h), see mana_spent.rb
32
36
  # {"times": [a, b]} / {"plus": [a, b]} / {"divide": [a, b]} arithmetic on amounts; division rounds down
33
37
  # (CR 107.1a). "-X" is {"times": ["x", -1]}.
34
- # A source that has left the battlefield is used as it last existed (CR 608.2h).
38
+ # A source that has left the battlefield is used as it last existed (CR 608.2h), and so is any object the
39
+ # power, toughness or mana value of which is read after it left the battlefield during the resolving
40
+ # spell or ability (a destroyed target, or {"result": n} of a sacrifice).
35
41
  class Amount
36
42
  SELECTOR_KINDS = %w[count power toughness greatest_power hand_size life graveyard_size distinct_names distinct_types
37
43
  distinct_colors distinct_land_types distinct_planeswalker_types
38
44
  life_gained_this_turn life_lost_this_turn dilemmas_this_turn returned_this_turn
39
45
  cards_drawn_this_turn cards_discarded_this_turn spells_cast_this_game speed
40
46
  noncombat_damage_this_turn noncombat_damage_last_turn turns_taken greatest_toughness
41
- mana_value].freeze
47
+ mana_value greatest_mana_value card_types library_size].freeze
42
48
  ARITHMETIC = %w[times plus divide].freeze
43
49
  BASIC_LAND_TYPES = %i[plains island swamp mountain forest].freeze # CR 305.6
44
50
  # Object history lists: kind -> turn_log key (Engine::TurnHistory).
@@ -46,7 +52,7 @@ module Enjine
46
52
  "sacrificed_this_turn" => "sacrificed", "attackers_this_turn" => "attackers",
47
53
  "exiled_this_turn" => "exiled" }.freeze
48
54
  KINDS = (SELECTOR_KINDS + ARITHMETIC + DEPARTED.keys + %w[counters spells_cast_this_turn lands_played_this_turn this_way
49
- mana_spent]).freeze
55
+ mana_spent sacrificed]).freeze
50
56
  # Player history counters: kind -> turn_log/game_log key.
51
57
  HISTORY = { "life_gained_this_turn" => "life_gained", "life_lost_this_turn" => "life_lost",
52
58
  "dilemmas_this_turn" => "dilemmas", "returned_this_turn" => "returned",
@@ -74,6 +80,7 @@ module Enjine
74
80
  return Departed.build(kind, ref, errors, "#{path}.#{kind}") if DEPARTED.key?(kind)
75
81
  return ThisWay.build(ref, errors, "#{path}.#{kind}", results_count) if kind == "this_way"
76
82
  return ManaSpent.build(ref, errors, "#{path}.#{kind}", **context) if kind == "mana_spent" # mana_spent.rb
83
+ return Sacrificed.build(ref, errors, "#{path}.#{kind}") if kind == "sacrificed"
77
84
 
78
85
  new(kind, Selector.build(ref, errors, "#{path}.#{kind}", **context))
79
86
  end
@@ -96,15 +103,9 @@ module Enjine
96
103
  when "count" then refs.size
97
104
  when "power", "toughness" then refs.empty? ? Amount.last_known(engine, ctx, @selector, @kind) : stats(engine, refs).sum
98
105
  when "greatest_power", "greatest_toughness" then stats(engine, refs).max.to_i
99
- when "mana_value" then refs.sum do
100
- Amount.mana_value(engine, engine.state.objects[it]) || engine.resolving_last_known(it)&.[]("mana_value").to_i
101
- end
102
- when "distinct_names" then live(engine, refs).map { engine.chars(it).name }.uniq.size
103
- when "distinct_types" then live(engine, refs).flat_map { Amount.creature_types(engine.chars(it)) }.uniq.size
104
- when "distinct_colors" then live(engine, refs).flat_map { engine.chars(it).colors }.uniq.size # CR 105.2
105
- when "distinct_land_types" then (live(engine, refs).flat_map { engine.chars(it).subtypes } & BASIC_LAND_TYPES).size
106
- when "distinct_planeswalker_types" # CR 205.3j: a planeswalker's subtypes are planeswalker types
107
- live(engine, refs).flat_map { (c = engine.chars(it)).type?(:planeswalker) ? c.subtypes : [] }.uniq.size
106
+ when "mana_value" then mana_values(engine, refs).sum
107
+ when "greatest_mana_value" then mana_values(engine, refs).max.to_i
108
+ when *DISTINCT then distinct(live(engine, refs).map { engine.chars(it) })
108
109
  else players(engine, refs).sum { player_value(engine, it) }
109
110
  end
110
111
  end
@@ -133,6 +134,17 @@ module Enjine
133
134
  lki ? lki[key].to_i : 0
134
135
  end
135
136
 
137
+ # How an object last existed on the battlefield if it left during the resolving spell or ability: by its
138
+ # old id (a destroyed target), or, for what an earlier effect moved (`moved`: a {"result": n} selector), as
139
+ # the new object it became (CR 400.7). A spell countered by the resolving one, as it last existed on the
140
+ # stack (Engine::TurnHistory#remember_spell!). Nil otherwise: a card in a graveyard looked at for itself is
141
+ # the card there.
142
+ def self.left_battlefield(engine, ref, moved: false)
143
+ return engine.departed_last_known(ref) if moved && engine.departed_last_known(ref)
144
+
145
+ engine.state.objects.key?(ref) ? nil : engine.resolving_last_known(ref)
146
+ end
147
+
136
148
  # How a source that has left the battlefield last existed: remembered by the resolving ability, or,
137
149
  # while its leaves-the-battlefield trigger is being checked (CR 603.4, 603.10a), the engine's.
138
150
  def self.source_lki(engine, ctx)
@@ -141,10 +153,42 @@ module Enjine
141
153
 
142
154
  private
143
155
 
156
+ DISTINCT = %w[card_types distinct_names distinct_types distinct_colors distinct_land_types
157
+ distinct_planeswalker_types].freeze
158
+ private_constant :DISTINCT
159
+
160
+ # How many different card types, names, creature types, colors, basic land types or planeswalker types
161
+ # the objects with these characteristics have between them.
162
+ def distinct(chars)
163
+ case @kind
164
+ when "card_types" then chars.flat_map(&:types).uniq.size # CR 205.2a
165
+ when "distinct_names" then chars.map(&:name).uniq.size
166
+ when "distinct_types" then chars.flat_map { Amount.creature_types(it) }.uniq.size
167
+ when "distinct_colors" then chars.flat_map(&:colors).uniq.size # CR 105.2
168
+ when "distinct_land_types" then (chars.flat_map(&:subtypes) & BASIC_LAND_TYPES).size
169
+ else chars.flat_map { it.type?(:planeswalker) ? it.subtypes : [] }.uniq.size # CR 205.3j
170
+ end
171
+ end
172
+
144
173
  def live(engine, refs) = refs.select { engine.state.objects.key?(it) }
145
174
 
175
+ # Each one's mana value; one that has left as it last existed (CR 608.2h).
176
+ def mana_values(engine, refs)
177
+ refs.map do |ref|
178
+ lki = Amount.left_battlefield(engine, ref, moved: @selector.kind == :result)
179
+ lki ? lki["mana_value"].to_i : Amount.mana_value(engine, engine.state.objects[ref]) || 0
180
+ end
181
+ end
182
+
183
+ # Each one's power or toughness; one that left the battlefield during the resolving spell or ability as it
184
+ # last existed (CR 608.2h), one that's simply gone not at all.
146
185
  def stats(engine, refs)
147
- live(engine, refs).map { engine.public_send(@kind.end_with?("toughness") ? :toughness : :power, engine.object(it)) }
186
+ key = @kind.end_with?("toughness") ? "toughness" : "power"
187
+ refs.filter_map do |ref|
188
+ if (lki = Amount.left_battlefield(engine, ref, moved: @selector.kind == :result)) then lki[key].to_i
189
+ elsif engine.state.objects.key?(ref) then engine.public_send(key, engine.object(ref))
190
+ end
191
+ end
148
192
  end
149
193
 
150
194
  def players(engine, refs) = refs.filter_map { Filters.player_index(engine, it) }
@@ -155,6 +199,7 @@ module Enjine
155
199
  when "life" then engine.player(index).life
156
200
  when "speed" then engine.speed(index) # CR 702.179f: 0 without speed
157
201
  when "graveyard_size" then engine.player(index).graveyard.size
202
+ when "library_size" then engine.player(index).library.size # CR 401.1
158
203
  when "spells_cast_this_game" then engine.game_log(index).fetch("spells", 0)
159
204
  when "turns_taken" then engine.game_log(index).fetch("turns", 0) # this turn included, if it's theirs
160
205
  when *LAST_TURN.keys then engine.last_turn_log(index).fetch(LAST_TURN.fetch(@kind), 0)
@@ -180,6 +225,29 @@ module Enjine
180
225
  def to_s = "that many"
181
226
  end
182
227
 
228
+ # {"sacrificed": "power" | "toughness" | "mana_value"}: "the sacrificed creature's power" — the total of the
229
+ # permanents sacrificed to pay the resolving spell's or ability's cost, as they last existed (CR 601.2h,
230
+ # 608.2h; recorded by Costs::SacrificePermanent). 0 when none was.
231
+ class Sacrificed
232
+ KEYS = %w[power toughness mana_value].freeze
233
+
234
+ def self.build(key, errors, path)
235
+ return new(key) if KEYS.include?(key)
236
+
237
+ errors << "#{path}: must be #{KEYS[0..-2].join(", ")} or #{KEYS.last}"
238
+ Fixed.new(0)
239
+ end
240
+
241
+ def initialize(key)
242
+ @key = key
243
+ freeze
244
+ end
245
+
246
+ def reads_power? = @key != "mana_value"
247
+ def value(engine, ctx) = (engine.state.objects[ctx.object]&.paid || []).sum { it[@key].to_i }
248
+ def to_s = "the sacrificed permanent's #{@key.tr("_", " ")}"
249
+ end
250
+
183
251
  # A fixed number.
184
252
  class Fixed
185
253
  attr_reader :n
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Enjine
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  # Comprehensive Rules document this engine follows.
6
6
  RULES_VERSION = "2026-11-06"
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: enjine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mehmet Cetin
@@ -30,6 +30,7 @@ files:
30
30
  - docs/guides/api.md
31
31
  - docs/guides/card-data-format.md
32
32
  - docs/guides/deck-builder.md
33
+ - docs/plans/dsl-gaps.md
33
34
  - docs/plans/standard.md
34
35
  - docs/reference/abilities.md
35
36
  - docs/reference/costs.md
@@ -91,6 +92,7 @@ files:
91
92
  - lib/enjine/engine/combat.rb
92
93
  - lib/enjine/engine/copies.rb
93
94
  - lib/enjine/engine/damage.rb
95
+ - lib/enjine/engine/defined_power_toughness.rb
94
96
  - lib/enjine/engine/designations.rb
95
97
  - lib/enjine/engine/emblems.rb
96
98
  - lib/enjine/engine/entering.rb
@@ -231,6 +233,7 @@ files:
231
233
  - lib/enjine/mechanics/keywords/kicker.rb
232
234
  - lib/enjine/mechanics/keywords/mobilize_and_firebending.rb
233
235
  - lib/enjine/mechanics/keywords/paradigm.rb
236
+ - lib/enjine/mechanics/keywords/plot.rb
234
237
  - lib/enjine/mechanics/keywords/power_up.rb
235
238
  - lib/enjine/mechanics/keywords/protection.rb
236
239
  - lib/enjine/mechanics/keywords/prowess.rb