enjine 0.1.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 (269) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +201 -0
  5. data/SUPPORTED.md +387 -0
  6. data/docs/ARCHITECTURE.md +141 -0
  7. data/docs/DESIGN.md +139 -0
  8. data/docs/MECHANICS_COVERAGE.md +670 -0
  9. data/docs/guides/adding-a-mechanic.md +60 -0
  10. data/docs/guides/api.md +614 -0
  11. data/docs/guides/card-data-format.md +1472 -0
  12. data/docs/guides/deck-builder.md +157 -0
  13. data/docs/plans/standard.md +371 -0
  14. data/docs/reference/abilities.md +1680 -0
  15. data/docs/reference/costs.md +339 -0
  16. data/docs/reference/counters.md +121 -0
  17. data/docs/reference/decisions.md +102 -0
  18. data/docs/reference/effects.md +2340 -0
  19. data/docs/reference/filters.md +172 -0
  20. data/docs/reference/keywords.md +647 -0
  21. data/docs/reference/subtypes.md +169 -0
  22. data/docs/reference/supertypes.md +31 -0
  23. data/docs/reference/triggers.md +797 -0
  24. data/docs/reference/types.md +216 -0
  25. data/exe/enjine +7 -0
  26. data/lib/enjine/actions/activate_mana_ability.rb +24 -0
  27. data/lib/enjine/actions/cast.rb +57 -0
  28. data/lib/enjine/actions/pass.rb +18 -0
  29. data/lib/enjine/actions/play_land.rb +52 -0
  30. data/lib/enjine/actions/special_action.rb +37 -0
  31. data/lib/enjine/actions.rb +20 -0
  32. data/lib/enjine/agents/busy.rb +27 -0
  33. data/lib/enjine/agents/random.rb +28 -0
  34. data/lib/enjine/card_definition.rb +433 -0
  35. data/lib/enjine/card_faces.rb +116 -0
  36. data/lib/enjine/catalog.rb +56 -0
  37. data/lib/enjine/characteristics.rb +39 -0
  38. data/lib/enjine/cli/text.rb +295 -0
  39. data/lib/enjine/cli.rb +312 -0
  40. data/lib/enjine/decisions/bottom.rb +29 -0
  41. data/lib/enjine/decisions/casting.rb +188 -0
  42. data/lib/enjine/decisions/combat.rb +75 -0
  43. data/lib/enjine/decisions/discard.rb +29 -0
  44. data/lib/enjine/decisions/effect_choice.rb +21 -0
  45. data/lib/enjine/decisions/enter_choice.rb +26 -0
  46. data/lib/enjine/decisions/legend_rule.rb +21 -0
  47. data/lib/enjine/decisions/mulligan.rb +26 -0
  48. data/lib/enjine/decisions/order_triggers.rb +21 -0
  49. data/lib/enjine/decisions/play_or_draw.rb +19 -0
  50. data/lib/enjine/decisions/priority.rb +22 -0
  51. data/lib/enjine/decisions/untap_choice.rb +35 -0
  52. data/lib/enjine/decisions.rb +22 -0
  53. data/lib/enjine/deck.rb +151 -0
  54. data/lib/enjine/deck_builder.rb +454 -0
  55. data/lib/enjine/docs.rb +90 -0
  56. data/lib/enjine/engine/ability_layers.rb +118 -0
  57. data/lib/enjine/engine/attachments.rb +86 -0
  58. data/lib/enjine/engine/casting/alt_casting.rb +254 -0
  59. data/lib/enjine/engine/casting/cast_choices.rb +292 -0
  60. data/lib/enjine/engine/casting/casting.rb +326 -0
  61. data/lib/enjine/engine/casting/cost_options.rb +99 -0
  62. data/lib/enjine/engine/casting/costs.rb +117 -0
  63. data/lib/enjine/engine/casting/payment.rb +353 -0
  64. data/lib/enjine/engine/casting/permissions.rb +412 -0
  65. data/lib/enjine/engine/casting/target_counts.rb +113 -0
  66. data/lib/enjine/engine/casting/waterbend.rb +90 -0
  67. data/lib/enjine/engine/characteristics.rb +379 -0
  68. data/lib/enjine/engine/class_levels.rb +25 -0
  69. data/lib/enjine/engine/combat.rb +315 -0
  70. data/lib/enjine/engine/copies.rb +208 -0
  71. data/lib/enjine/engine/damage.rb +196 -0
  72. data/lib/enjine/engine/designations.rb +46 -0
  73. data/lib/enjine/engine/emblems.rb +37 -0
  74. data/lib/enjine/engine/entering.rb +156 -0
  75. data/lib/enjine/engine/face_down.rb +30 -0
  76. data/lib/enjine/engine/faces.rb +222 -0
  77. data/lib/enjine/engine/follow_ups.rb +27 -0
  78. data/lib/enjine/engine/mana_sources.rb +230 -0
  79. data/lib/enjine/engine/phasing.rb +74 -0
  80. data/lib/enjine/engine/pregame.rb +95 -0
  81. data/lib/enjine/engine/priority.rb +47 -0
  82. data/lib/enjine/engine/resolution.rb +168 -0
  83. data/lib/enjine/engine/rules.rb +287 -0
  84. data/lib/enjine/engine/sagas.rb +41 -0
  85. data/lib/enjine/engine/speed.rb +104 -0
  86. data/lib/enjine/engine/spell_grants.rb +46 -0
  87. data/lib/enjine/engine/state_based_actions.rb +157 -0
  88. data/lib/enjine/engine/triggers.rb +364 -0
  89. data/lib/enjine/engine/turn_history.rb +240 -0
  90. data/lib/enjine/engine/turn_structure.rb +224 -0
  91. data/lib/enjine/engine/zones.rb +278 -0
  92. data/lib/enjine/engine.rb +55 -0
  93. data/lib/enjine/errors.rb +62 -0
  94. data/lib/enjine/events.rb +161 -0
  95. data/lib/enjine/frames.rb +84 -0
  96. data/lib/enjine/fuzz.rb +112 -0
  97. data/lib/enjine/game.rb +204 -0
  98. data/lib/enjine/history.rb +26 -0
  99. data/lib/enjine/mana.rb +142 -0
  100. data/lib/enjine/mechanics/abilities/additional_triggers.rb +86 -0
  101. data/lib/enjine/mechanics/abilities/chosen_values.rb +213 -0
  102. data/lib/enjine/mechanics/abilities/cost_changes.rb +154 -0
  103. data/lib/enjine/mechanics/abilities/damage_replacements.rb +569 -0
  104. data/lib/enjine/mechanics/abilities/double_counters.rb +71 -0
  105. data/lib/enjine/mechanics/abilities/entering_choices.rb +91 -0
  106. data/lib/enjine/mechanics/abilities/entering_copy.rb +69 -0
  107. data/lib/enjine/mechanics/abilities/enters_tapped.rb +159 -0
  108. data/lib/enjine/mechanics/abilities/enters_with_counters.rb +116 -0
  109. data/lib/enjine/mechanics/abilities/mana_ability.rb +209 -0
  110. data/lib/enjine/mechanics/abilities/play_permissions.rb +527 -0
  111. data/lib/enjine/mechanics/abilities/player_rules.rb +169 -0
  112. data/lib/enjine/mechanics/abilities/replacement_effects.rb +368 -0
  113. data/lib/enjine/mechanics/abilities/restrictions.rb +144 -0
  114. data/lib/enjine/mechanics/abilities/spell_keywords.rb +55 -0
  115. data/lib/enjine/mechanics/abilities/stack_abilities.rb +542 -0
  116. data/lib/enjine/mechanics/abilities/static.rb +365 -0
  117. data/lib/enjine/mechanics/abilities/tapping_power.rb +71 -0
  118. data/lib/enjine/mechanics/abilities/turn_rules.rb +49 -0
  119. data/lib/enjine/mechanics/card_spec.rb +177 -0
  120. data/lib/enjine/mechanics/copy_except.rb +73 -0
  121. data/lib/enjine/mechanics/costs/base.rb +115 -0
  122. data/lib/enjine/mechanics/costs/behold.rb +76 -0
  123. data/lib/enjine/mechanics/costs/discard.rb +65 -0
  124. data/lib/enjine/mechanics/costs/exert.rb +29 -0
  125. data/lib/enjine/mechanics/costs/exile_cards.rb +39 -0
  126. data/lib/enjine/mechanics/costs/from_graveyard.rb +45 -0
  127. data/lib/enjine/mechanics/costs/get_counters.rb +39 -0
  128. data/lib/enjine/mechanics/costs/mana.rb +37 -0
  129. data/lib/enjine/mechanics/costs/one_of.rb +49 -0
  130. data/lib/enjine/mechanics/costs/pay_life.rb +50 -0
  131. data/lib/enjine/mechanics/costs/remove_counters.rb +51 -0
  132. data/lib/enjine/mechanics/costs/sacrifice.rb +59 -0
  133. data/lib/enjine/mechanics/costs/tap.rb +56 -0
  134. data/lib/enjine/mechanics/costs/waterbend.rb +29 -0
  135. data/lib/enjine/mechanics/counters/kinds.rb +190 -0
  136. data/lib/enjine/mechanics/effects/add_subtypes.rb +30 -0
  137. data/lib/enjine/mechanics/effects/airbend.rb +41 -0
  138. data/lib/enjine/mechanics/effects/amass.rb +124 -0
  139. data/lib/enjine/mechanics/effects/assimilate.rb +72 -0
  140. data/lib/enjine/mechanics/effects/base.rb +148 -0
  141. data/lib/enjine/mechanics/effects/become.rb +182 -0
  142. data/lib/enjine/mechanics/effects/behold.rb +48 -0
  143. data/lib/enjine/mechanics/effects/blight.rb +94 -0
  144. data/lib/enjine/mechanics/effects/choose_cards.rb +168 -0
  145. data/lib/enjine/mechanics/effects/copy.rb +370 -0
  146. data/lib/enjine/mechanics/effects/counter_spell.rb +21 -0
  147. data/lib/enjine/mechanics/effects/counters.rb +161 -0
  148. data/lib/enjine/mechanics/effects/damage.rb +72 -0
  149. data/lib/enjine/mechanics/effects/delayed_triggers.rb +112 -0
  150. data/lib/enjine/mechanics/effects/destroy.rb +22 -0
  151. data/lib/enjine/mechanics/effects/discard.rb +86 -0
  152. data/lib/enjine/mechanics/effects/draw.rb +16 -0
  153. data/lib/enjine/mechanics/effects/earthbend.rb +70 -0
  154. data/lib/enjine/mechanics/effects/emblem.rb +52 -0
  155. data/lib/enjine/mechanics/effects/endure.rb +63 -0
  156. data/lib/enjine/mechanics/effects/exile.rb +49 -0
  157. data/lib/enjine/mechanics/effects/extra_turn.rb +122 -0
  158. data/lib/enjine/mechanics/effects/faces.rb +115 -0
  159. data/lib/enjine/mechanics/effects/fight.rb +76 -0
  160. data/lib/enjine/mechanics/effects/flip_coins.rb +97 -0
  161. data/lib/enjine/mechanics/effects/gain_control.rb +94 -0
  162. data/lib/enjine/mechanics/effects/game_end.rb +42 -0
  163. data/lib/enjine/mechanics/effects/library.rb +327 -0
  164. data/lib/enjine/mechanics/effects/life.rb +83 -0
  165. data/lib/enjine/mechanics/effects/look_at_hand.rb +17 -0
  166. data/lib/enjine/mechanics/effects/loot.rb +118 -0
  167. data/lib/enjine/mechanics/effects/mana.rb +96 -0
  168. data/lib/enjine/mechanics/effects/may.rb +82 -0
  169. data/lib/enjine/mechanics/effects/meld.rb +77 -0
  170. data/lib/enjine/mechanics/effects/mill.rb +22 -0
  171. data/lib/enjine/mechanics/effects/pay.rb +231 -0
  172. data/lib/enjine/mechanics/effects/phasing.rb +51 -0
  173. data/lib/enjine/mechanics/effects/proliferate.rb +72 -0
  174. data/lib/enjine/mechanics/effects/pump.rb +60 -0
  175. data/lib/enjine/mechanics/effects/put_in_library.rb +98 -0
  176. data/lib/enjine/mechanics/effects/return_choice.rb +29 -0
  177. data/lib/enjine/mechanics/effects/return_to_battlefield.rb +80 -0
  178. data/lib/enjine/mechanics/effects/return_to_hand.rb +20 -0
  179. data/lib/enjine/mechanics/effects/roll_dice.rb +45 -0
  180. data/lib/enjine/mechanics/effects/sacrifice.rb +118 -0
  181. data/lib/enjine/mechanics/effects/scry_and_surveil.rb +74 -0
  182. data/lib/enjine/mechanics/effects/search.rb +156 -0
  183. data/lib/enjine/mechanics/effects/tap_untap.rb +23 -0
  184. data/lib/enjine/mechanics/effects/tokens.rb +76 -0
  185. data/lib/enjine/mechanics/filters/any_target.rb +15 -0
  186. data/lib/enjine/mechanics/filters/base.rb +386 -0
  187. data/lib/enjine/mechanics/filters/graveyard.rb +23 -0
  188. data/lib/enjine/mechanics/filters/one_of.rb +55 -0
  189. data/lib/enjine/mechanics/filters/permanents.rb +52 -0
  190. data/lib/enjine/mechanics/filters/players.rb +20 -0
  191. data/lib/enjine/mechanics/filters/stack.rb +97 -0
  192. data/lib/enjine/mechanics/follow_up.rb +46 -0
  193. data/lib/enjine/mechanics/keywords/alternative_costs.rb +361 -0
  194. data/lib/enjine/mechanics/keywords/beam_me_up.rb +78 -0
  195. data/lib/enjine/mechanics/keywords/changeling.rb +58 -0
  196. data/lib/enjine/mechanics/keywords/convoke.rb +77 -0
  197. data/lib/enjine/mechanics/keywords/crew.rb +155 -0
  198. data/lib/enjine/mechanics/keywords/cycling.rb +58 -0
  199. data/lib/enjine/mechanics/keywords/decayed.rb +25 -0
  200. data/lib/enjine/mechanics/keywords/devour.rb +61 -0
  201. data/lib/enjine/mechanics/keywords/evergreen.rb +54 -0
  202. data/lib/enjine/mechanics/keywords/extort.rb +26 -0
  203. data/lib/enjine/mechanics/keywords/flags.rb +46 -0
  204. data/lib/enjine/mechanics/keywords/foretell.rb +45 -0
  205. data/lib/enjine/mechanics/keywords/gift.rb +50 -0
  206. data/lib/enjine/mechanics/keywords/hideaway.rb +28 -0
  207. data/lib/enjine/mechanics/keywords/increment.rb +31 -0
  208. data/lib/enjine/mechanics/keywords/job_select.rb +29 -0
  209. data/lib/enjine/mechanics/keywords/kicker.rb +92 -0
  210. data/lib/enjine/mechanics/keywords/mobilize_and_firebending.rb +84 -0
  211. data/lib/enjine/mechanics/keywords/paradigm.rb +91 -0
  212. data/lib/enjine/mechanics/keywords/power_up.rb +48 -0
  213. data/lib/enjine/mechanics/keywords/protection.rb +111 -0
  214. data/lib/enjine/mechanics/keywords/prowess.rb +30 -0
  215. data/lib/enjine/mechanics/keywords/riot.rb +35 -0
  216. data/lib/enjine/mechanics/keywords/saddle.rb +115 -0
  217. data/lib/enjine/mechanics/keywords/speed.rb +89 -0
  218. data/lib/enjine/mechanics/keywords/station.rb +163 -0
  219. data/lib/enjine/mechanics/keywords/storied.rb +41 -0
  220. data/lib/enjine/mechanics/keywords/storm_and_cascade.rb +86 -0
  221. data/lib/enjine/mechanics/keywords/sunburst.rb +29 -0
  222. data/lib/enjine/mechanics/keywords/suspend.rb +191 -0
  223. data/lib/enjine/mechanics/keywords/teamwork.rb +45 -0
  224. data/lib/enjine/mechanics/keywords/ward.rb +67 -0
  225. data/lib/enjine/mechanics/linked.rb +67 -0
  226. data/lib/enjine/mechanics/mana_spent.rb +99 -0
  227. data/lib/enjine/mechanics/selector.rb +207 -0
  228. data/lib/enjine/mechanics/subtypes/auras_and_equipment.rb +176 -0
  229. data/lib/enjine/mechanics/subtypes/basic_land_types.rb +36 -0
  230. data/lib/enjine/mechanics/subtypes/class_levels.rb +102 -0
  231. data/lib/enjine/mechanics/subtypes/saga.rb +89 -0
  232. data/lib/enjine/mechanics/supertypes/basic.rb +17 -0
  233. data/lib/enjine/mechanics/supertypes/legendary.rb +18 -0
  234. data/lib/enjine/mechanics/triggers/ability_triggers.rb +51 -0
  235. data/lib/enjine/mechanics/triggers/any_of.rb +43 -0
  236. data/lib/enjine/mechanics/triggers/base.rb +228 -0
  237. data/lib/enjine/mechanics/triggers/casting.rb +219 -0
  238. data/lib/enjine/mechanics/triggers/combat.rb +123 -0
  239. data/lib/enjine/mechanics/triggers/damage.rb +130 -0
  240. data/lib/enjine/mechanics/triggers/dilemma.rb +37 -0
  241. data/lib/enjine/mechanics/triggers/draw_and_discard.rb +85 -0
  242. data/lib/enjine/mechanics/triggers/keyword_actions.rb +55 -0
  243. data/lib/enjine/mechanics/triggers/life.rb +35 -0
  244. data/lib/enjine/mechanics/triggers/steps.rb +39 -0
  245. data/lib/enjine/mechanics/triggers/tap_and_counters.rb +111 -0
  246. data/lib/enjine/mechanics/triggers/zone_changes.rb +249 -0
  247. data/lib/enjine/mechanics/types/kindred.rb +30 -0
  248. data/lib/enjine/mechanics/types/land.rb +24 -0
  249. data/lib/enjine/mechanics/types/permanent_types.rb +67 -0
  250. data/lib/enjine/mechanics/types/spell_types.rb +38 -0
  251. data/lib/enjine/mechanics/values.rb +675 -0
  252. data/lib/enjine/mechanics.rb +74 -0
  253. data/lib/enjine/player.rb +30 -0
  254. data/lib/enjine/primitive.rb +61 -0
  255. data/lib/enjine/replacements.rb +191 -0
  256. data/lib/enjine/rng.rb +57 -0
  257. data/lib/enjine/rules_monitor.rb +404 -0
  258. data/lib/enjine/setup.rb +45 -0
  259. data/lib/enjine/snapshot/player_checks.rb +86 -0
  260. data/lib/enjine/snapshot/turn_checks.rb +91 -0
  261. data/lib/enjine/snapshot.rb +307 -0
  262. data/lib/enjine/state.rb +173 -0
  263. data/lib/enjine/steps.rb +117 -0
  264. data/lib/enjine/tokens.rb +25 -0
  265. data/lib/enjine/verify.rb +233 -0
  266. data/lib/enjine/version.rb +7 -0
  267. data/lib/enjine/view.rb +162 -0
  268. data/lib/enjine.rb +46 -0
  269. metadata +315 -0
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Characteristics after continuous effects (CR 613).
6
+ #
7
+ # The layer pass runs over the whole battlefield in three stages, each finished for every
8
+ # object before the next starts, so an effect in a later layer sees the earlier layers' result:
9
+ # layers 1-4 the object's own changes (station symbols, assimilate) and type-changing effects
10
+ # layers 5-6 keywords and granted abilities
11
+ # layer 7 7a characteristic-defining abilities, 7b setting effects, 7c modifications (effects,
12
+ # counters, static pumps), 7d switching (none yet)
13
+ # While the pass runs, `chars` answers from the stages done so far, so a static ability counting
14
+ # "artifact creatures you control" sees types after layer 4 without recursing (CR 613.8 guards keep
15
+ # statics from reading power, toughness or combat status).
16
+ module Characteristics
17
+ # The object's current characteristics. Off the battlefield: as printed (with a characteristic-
18
+ # defining power/toughness, CR 604.3). On the battlefield: the result of a whole-board layer pass,
19
+ # cached until state changes.
20
+ def chars(object)
21
+ object = self.object(object) if object.is_a?(String)
22
+ return emblem_characteristics(object) if object.kind == "emblem" # CR 114.3 (Engine::Emblems)
23
+ return Enjine::Characteristics::FACE_DOWN if face_down?(object) && @face_up != object.id # CR 406.3a
24
+
25
+ # layer 1 (CR 613.2a-c): its copiable values
26
+ return board_characteristics.fetch(object.id) { copy_base(object) } if object.zone == :battlefield
27
+ return @options_memo[object.id] ||= off_board_chars(object) if @options_memo && object.zone == :hand
28
+
29
+ off_board_chars(object)
30
+ end
31
+
32
+ # Off the battlefield. The options of one decision ask about each card in hand often (Engine#options).
33
+ def off_board_chars(object)
34
+ defn = definition(object)
35
+ base = copy_base(object)
36
+ base = defined_power_toughness(object, base, defn) if defined_pt_copied?(object)
37
+ base = with_spell_grants(object, base) if object.zone == :stack # Engine::SpellGrants
38
+ with_max_speed(object, base) # CR 702.178b: e.g. an ability that works from the graveyard (Engine::Speed)
39
+ end
40
+
41
+ def power(object) = chars(object).power.to_i
42
+ def toughness(object) = chars(object).toughness.to_i
43
+ def power_toughness(object) = chars(object).then { [it.power.to_i, it.toughness.to_i] }
44
+ def loyalty(object) = object.counters.fetch("loyalty", 0)
45
+
46
+ # Events after which characteristics may differ; any other event (taps, mana, priority)
47
+ # keeps the cached layer pass. Replacing the whole state (import, fork, rollback) is
48
+ # detected separately by identity. While a static ability has a condition or a computed bonus, or a
49
+ # permanent has a characteristic-defining power/toughness, any event may change the result, so every
50
+ # event drops the cache (`@volatile`, see Engine#emit).
51
+ LAYER_EVENTS = [Events::ZoneChanged, Events::CountersChanged, Events::EffectStarted,
52
+ Events::EffectsEnded, Events::Attached, Events::Unattached, Events::ControlChanged,
53
+ Events::PhasedOut, Events::PhasedIn, Events::SpeedChanged,
54
+ Events::Transformed, Events::ClassLevelChanged, Events::EmblemCreated].freeze
55
+
56
+ def touch! = @epoch = @epoch.to_i + 1
57
+ def volatile_layers? = @volatile
58
+
59
+ def board_characteristics
60
+ return @layering if @layering # mid-pass: the layers applied so far
61
+ return @board if @board && @board_epoch == @epoch && @board_state.equal?(state)
62
+
63
+ @board_epoch = @epoch
64
+ @board_state = state
65
+ @board = apply_layers
66
+ end
67
+
68
+ # CR 613.1, 613.4. Only modified objects get an entry; the rest use their printed characteristics.
69
+ def apply_layers
70
+ statics = static_sources
71
+ effects = state.effects
72
+ @volatile = statics.any? { |_, ability| ability.condition || ability.computed? } || defined_on_battlefield?
73
+ return {} if statics.empty? && effects.empty? && !@volatile && printed_board?
74
+
75
+ @layering = {}
76
+ ids = state.battlefield
77
+ active = statics.select { |source, ability| ability.active?(self, source) }
78
+ ids.each { @layering[it] = type_layers(object(it), active, effects) }
79
+ later_layers(ids, statics, ids.select { losing_abilities?(object(it), active, effects) }, effects)
80
+ @layering.reject { |id, c| c.equal?(definition(object(id)).printed_characteristics) }
81
+ ensure
82
+ @layering = nil
83
+ end
84
+
85
+ # Layers 5-7. CR 613.8a: a static ability of a permanent that loses all abilities in layer 6 no longer
86
+ # exists there, so its layer 5-7 effects depend on the removal and don't apply (its layer 1-4 effects
87
+ # already have). `stripped`: the ids of those permanents.
88
+ def later_layers(ids, statics, stripped, effects)
89
+ live = proc { |source, ability| !stripped.include?(source.id) && ability.active?(self, source) }
90
+ active = statics.select(&live)
91
+ ids.each { @layering[it] = ability_layers(object(it), @layering[it], active, effects) }
92
+ active = statics.select(&live)
93
+ bonuses = active.to_h { |source, ability| [[source.id, ability], ability.bonus(self, source)] }
94
+ ids.each do |id|
95
+ @layering[id] = power_toughness_layers(object(id), @layering[id], active, bonuses, effects, stripped: stripped.include?(id))
96
+ end
97
+ end
98
+
99
+ # Without effects, every permanent is as printed: none has counters or is a noncreature with printed
100
+ # power and toughness (an uncrewed Vehicle has none, CR 208.3).
101
+ # A max speed ability applying changes it too (Engine::Speed).
102
+ def printed_board?
103
+ speedy = state.players.each_index.any? { max_speed?(it) }
104
+ state.battlefield.none? do |id|
105
+ obj = object(id)
106
+ !obj.counters.empty? || printed_noncreature_pt?(obj) || (speedy && !active_max_speed(obj, obj.controller).empty?) ||
107
+ obj.memory.key?("class_level") || obj.memory.key?("haste_while_controlled_by") # CR 717.2a, 702.62a
108
+ end
109
+ end
110
+
111
+ def defined_on_battlefield? = state.battlefield.any? { definition(object(it)).defined_pt? }
112
+
113
+ def facts(obj, current) = Mechanics::Filters::Facts.new(chars: current, counters: obj.counters, token: token?(obj))
114
+
115
+ # ---- layer 1 (CR 613.2a-c, 707.2) ---------------------------------------------------------
116
+
117
+ # The object's copiable values (CR 707.2): the printed characteristics of its card, which is the
118
+ # copied card while a copy effect applies (see Engine::Copies), with the exceptions of the copy effect
119
+ # (CR 707.9a-b: "except it isn't legendary", "except it has menace"). Then the characteristic-defining
120
+ # changeling (CR 702.73a, all zones) and abilities a card outside the battlefield gained (CR 113.6b).
121
+ def copy_base(obj)
122
+ defn = definition(obj)
123
+ except = copy_exceptions(obj)
124
+ base = if except && !except.empty?
125
+ Mechanics::Keywords::Changeling.apply(Copies.apply_exceptions(defn.printed_characteristics, except))
126
+ elsif defn.keywords.include?(:changeling)
127
+ (@changeling_printed ||= {})[defn.id] ||= Mechanics::Keywords::Changeling.apply(defn.printed_characteristics)
128
+ else
129
+ defn.printed_characteristics
130
+ end
131
+ return base if state.effects.none? { it["kind"] == "card_ability" }
132
+
133
+ granted = card_granted_abilities(obj)
134
+ granted.empty? ? base : base.with(abilities: (base.abilities + granted).freeze)
135
+ end
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
+ # Layers 1-4: the object's own changes and resolved effects that add types (#own_layers),
144
+ # then static abilities that add card types ("this Vehicle is an artifact creature", CR 205.1b), then effects
145
+ # that set its creature types (CR 205.1a, 613.1d), statics before resolved effects, each in timestamp order.
146
+ # A noncreature permanent has no power or toughness, even a Vehicle with them printed (CR 208.3, 301.7a).
147
+ def type_layers(obj, statics, effects)
148
+ base = own_layers(obj, copy_base(obj), effects)
149
+ types = nil
150
+ subtypes = []
151
+ matching = statics.select { |source, ability| ability.type_changes? && ability.affects?(obj, source, facts(obj, base)) }
152
+ # Layers 3-4 in timestamp order (CR 613.7a): a name or card types set later win (CR 612.8, 205.1a).
153
+ matching = matching.each_with_index.sort_by { |(source, _), i| [timestamp_of(source), i] }.map(&:first) if matching.size > 1
154
+ matching.each do |source, ability|
155
+ base = base.with(name: ability.name) if ability.name
156
+ base = with_set_types(base, ability.set_types) if ability.set_types
157
+ types = ability.creature_types if ability.creature_types
158
+ base = with_added_types(base, ability.card_types) unless ability.card_types.empty?
159
+ subtypes |= ability.added_subtypes(source)
160
+ end
161
+ effects.each { types = it["types"].map(&:to_sym) if it["kind"] == "creature_types" && it["affected"].include?(obj.id) }
162
+ base = base.with(subtypes: ((base.subtypes & Characteristics::NON_CREATURE_SUBTYPES) | types).freeze) if types
163
+ base = with_gained_subtypes(base, subtypes - base.subtypes) unless (subtypes - base.subtypes).empty?
164
+ base.creature? || base.power.nil? ? base : base.with(power: nil, toughness: nil)
165
+ end
166
+
167
+ # Subtypes it has in addition to its others; a land that gains a basic land type has that type's mana
168
+ # ability (CR 305.6).
169
+ def with_gained_subtypes(base, gained)
170
+ intrinsic = base.land? ? gained.flat_map { CardDefinition.intrinsic_instances(it) } : []
171
+ base.with(subtypes: (base.subtypes | gained).freeze,
172
+ abilities: intrinsic.empty? ? base.abilities : (base.abilities + intrinsic).freeze)
173
+ end
174
+
175
+ # Card types added "in addition to its other types" (CR 205.1b). A permanent that becomes a creature has
176
+ # its printed power and toughness (CR 301.7b), or 0/0 without any, until a layer 7 effect changes them.
177
+ def with_added_types(base, added)
178
+ return base if (added - base.types).empty?
179
+
180
+ base = base.with(types: (base.types | added).freeze)
181
+ base.creature? && base.power.nil? ? base.with(power: 0, toughness: 0) : base
182
+ end
183
+
184
+ # "Is a black Skeleton creature" (CR 205.1a): the card types are exactly these, and subtypes correlated with
185
+ # a card type it no longer has are lost (creature types without creature, an Equipment without artifact).
186
+ def with_set_types(base, set)
187
+ kept = base.subtypes.select do |sub|
188
+ correlated = CORRELATED_SUBTYPES.find { |_, list| list.include?(sub) }&.first || :creature
189
+ set.include?(correlated)
190
+ end
191
+ base = base.with(types: set, subtypes: kept.freeze)
192
+ base.creature? && base.power.nil? ? base.with(power: 0, toughness: 0) : base
193
+ end
194
+
195
+ def printed_noncreature_pt?(obj) = definition(obj).then { !it.creature? && !it.power.nil? }
196
+
197
+ # Layer 7 (CR 613.4): 7a defined power/toughness (not once the ability defining them is lost in layer 6,
198
+ # CR 613.1f), 7b setting effects (statics by their source's timestamp and resolved effects, the latest
199
+ # last), 7c counters, static bonuses and pumps (CR 613.4c). Objects without power and toughness are
200
+ # unchanged.
201
+ def power_toughness_layers(obj, base, statics, bonuses, effects, stripped: false)
202
+ defn = definition(obj)
203
+ if base.creature? && defined_pt_copied?(obj)
204
+ # without the ability defining it, an undefined power or toughness is 0 (CR 107.2)
205
+ base = stripped ? base.with(power: base.power || 0, toughness: base.toughness || 0) : defined_power_toughness(obj, base, defn)
206
+ end
207
+ return base if base.power.nil?
208
+
209
+ power, toughness = base_setting(obj, base, statics, effects) || [base.power, base.toughness]
210
+ delta = Mechanics::Counters.power_toughness(obj.counters)
211
+ statics.each do |source, ability|
212
+ next unless ability.pump? && ability.affects?(obj, source, facts(obj, base))
213
+
214
+ bonus = bonuses.fetch([source.id, ability])
215
+ delta = [delta[0] + bonus[0], delta[1] + bonus[1]]
216
+ end
217
+ effects.each do
218
+ delta = [delta[0] + it["power"], delta[1] + it["toughness"]] if it["kind"] == "pump" && it["affected"].include?(obj.id)
219
+ end
220
+ power += delta[0]
221
+ toughness += delta[1]
222
+ return base if power == base.power && toughness == base.toughness
223
+
224
+ base.with(power: power, toughness: toughness)
225
+ end
226
+
227
+ # Layer 7b (CR 613.4b): the latest base power/toughness setting, or nil.
228
+ def base_setting(obj, base, statics, effects)
229
+ sets = statics.filter_map do |source, ability|
230
+ next unless ability.base_power_toughness && ability.affects?(obj, source, facts(obj, base))
231
+
232
+ [timestamp_of(source), ability.base_power_toughness.values_at("power", "toughness")]
233
+ end
234
+ effects.each do |effect|
235
+ next unless effect["kind"] == "base_power_toughness" && effect["affected"].include?(obj.id)
236
+
237
+ sets << [effect["timestamp"], effect.values_at("power", "toughness")]
238
+ end
239
+ sets.max_by(&:first)&.last
240
+ end
241
+
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
+ # [[source object, StaticAbility], ...] for permanents on the battlefield (printed abilities:
250
+ # an ability granting static abilities would need layer dependencies, CR 613.8).
251
+ def static_sources
252
+ state.battlefield.flat_map do |id|
253
+ obj = object(id)
254
+ own_ability_instances(obj).filter_map { [obj, it.ability] if it.ability.kind == :static }
255
+ end + emblems.flat_map { |obj| chars(obj).abilities.filter_map { [obj, it.ability] if it.ability.kind == :static } }
256
+ end
257
+
258
+ # Starts a continuous effect from a resolving spell or ability (CR 611.2).
259
+ # The affected set is locked in now (CR 611.2c). An indefinite effect ("permanent") on objects that
260
+ # have all left the battlefield no longer does anything and is dropped. `details` may set "duration".
261
+ def add_continuous_effect!(kind, ctx, affected, details)
262
+ state.effects.reject! do |effect| # a phased-out permanent is still there (CR 702.26d, 702.26f)
263
+ effect["duration"] == "permanent" && effect["affected"].none? { state.battlefield.include?(it) || state.phased_out.include?(it) }
264
+ end
265
+ details = details.dup
266
+ duration = details.delete("duration") || "end_of_turn"
267
+ effect = { "kind" => kind, "timestamp" => next_timestamp!, "controller" => ctx.controller,
268
+ "affected" => affected, "duration" => duration }.merge(details)
269
+ state.effects << effect
270
+ emit Events::EffectStarted.new(kind: kind, affected: affected, details: details)
271
+ end
272
+
273
+ def next_timestamp!
274
+ state.next_id.tap { state.next_id += 1 }
275
+ end
276
+
277
+ # CR 514.2: damage wears off and "until end of turn" effects end.
278
+ def cleanup_turn!
279
+ expire_permissions! # Engine::Permissions
280
+ end_copy_effects! # Engine::Copies
281
+ state.battlefield.each { remove_damage!(object(it)) unless chars(it).keyword?(:keeps_damage) }
282
+ ending = state.effects.count { it["duration"] == "end_of_turn" }
283
+ return if ending.zero?
284
+
285
+ state.effects.reject! { it["duration"] == "end_of_turn" }
286
+ emit Events::EffectsEnded.new(count: ending)
287
+ end
288
+ # Subtypes that aren't creature types (CR 205.3g-i). "Becomes a Borg artifact creature"
289
+ # replaces creature types only (CR 205.1b).
290
+ # The card type each of them is correlated with (CR 205.1a).
291
+ CORRELATED_SUBTYPES = {
292
+ artifact: %i[attraction blood bobblehead book clue communicator contraption equipment food fortification gold
293
+ heartwood incubator infinity junk lander map mutagen powerstone spacecraft stone treasure vehicle
294
+ vibranium],
295
+ enchantment: %i[aura background cartouche case class curse plan role room rune saga shard shrine],
296
+ land: %i[cave desert forest gate island lair locus mine mountain plains planet power_plant sphere swamp tower town
297
+ urzas]
298
+ }.freeze
299
+ NON_CREATURE_SUBTYPES = CORRELATED_SUBTYPES.values.flatten.freeze
300
+
301
+ # ---- layers 4 and 7b from the object's own state ------------------------------------
302
+
303
+ # Station symbols that apply: "as long as this permanent has N or more charge counters" (CR 722.2a).
304
+ def active_station_symbols(obj)
305
+ charge = obj.counters.fetch("charge", 0)
306
+ defn = catalog.fetch(obj.card)
307
+ defn.ability_instances.select { it.ability.kind == :station_symbol && charge >= it.ability.threshold }
308
+ end
309
+
310
+ # The abilities the object has from its own card: printed ones plus those of applying station symbols
311
+ # and max speed abilities (Engine::Speed).
312
+ def own_ability_instances(obj)
313
+ printed = catalog.fetch(obj.card).ability_instances
314
+ symbols = active_max_speed(obj, obj.controller) + active_class_levels(obj) # CR 717.2a (Engine::ClassLevels)
315
+ symbols += active_station_symbols(obj) if obj.counters.fetch("charge", 0).positive?
316
+ return printed if symbols.empty?
317
+
318
+ printed + symbols.flat_map { catalog.fetch(obj.card).nested_instances(it.ref) }
319
+ end
320
+
321
+ # The printed characteristics changed by the object's own station symbols (CR 722.2a-b: abilities,
322
+ # layer 4 "is a creature in addition to its other types", layer 7b base power/toughness) and by
323
+ # assimilation (CR 701.72, 205.1b: an artifact creature whose creature types are replaced by Borg).
324
+ def own_layers(obj, printed, effects)
325
+ symbols = obj.counters.fetch("charge", 0).positive? ? active_station_symbols(obj) : []
326
+ # same shape: keywords and nested abilities (CR 702.178a, 717.2a)
327
+ symbols += active_max_speed(obj, obj.controller) + active_class_levels(obj)
328
+ assimilated = !effects.empty? && effects.any? { it["kind"] == "assimilate" && it["affected"].include?(obj.id) }
329
+ added = effects.empty? ? [] : added_subtypes(obj, effects)
330
+ becomes = effects.empty? ? [] : effects.select { it["kind"] == "add_types" && it["affected"].include?(obj.id) }
331
+ return printed if symbols.empty? && !assimilated && added.empty? && becomes.empty?
332
+
333
+ attrs = printed.to_h
334
+ symbols.each { station_symbol_layers(attrs, it, catalog.fetch(obj.card)) }
335
+ assimilated_layers(attrs) if assimilated
336
+ attrs[:subtypes] = attrs[:subtypes] | added
337
+ becomes.each { added_type_layers(attrs, it) }
338
+ Enjine::Characteristics.new(**attrs.transform_values { it.is_a?(Array) ? it.freeze : it })
339
+ end
340
+
341
+ # "Becomes an artifact creature", "becomes a legendary Spider Hero in addition to its other types" (CR 205.1b):
342
+ # card types, supertypes and subtypes are added, none lost. Power/toughness: see Characteristics#with_added_types.
343
+ def added_type_layers(attrs, effect)
344
+ attrs[:types] = attrs[:types] | effect["types"].map(&:to_sym)
345
+ attrs[:supertypes] = attrs[:supertypes] | effect["supertypes"].map(&:to_sym)
346
+ attrs[:subtypes] = attrs[:subtypes] | effect["subtypes"].map(&:to_sym)
347
+ return unless attrs[:types].include?(:creature) && attrs[:power].nil?
348
+
349
+ attrs[:power] = 0
350
+ attrs[:toughness] = 0
351
+ end
352
+
353
+ def station_symbol_layers(attrs, inst, defn)
354
+ symbol = inst.ability
355
+ attrs[:abilities] = attrs[:abilities] + defn.nested_instances(inst.ref)
356
+ attrs[:keywords] = attrs[:keywords] | symbol.keywords
357
+ return unless symbol.power
358
+
359
+ attrs[:types] = attrs[:types] | [:creature] # CR 722.2b
360
+ attrs[:power] = symbol.power
361
+ attrs[:toughness] = symbol.toughness
362
+ end
363
+
364
+ def assimilated_layers(attrs)
365
+ attrs[:types] = attrs[:types] | %i[artifact creature]
366
+ attrs[:subtypes] = (attrs[:subtypes] & NON_CREATURE_SUBTYPES) | [:borg]
367
+ attrs[:power] ||= 0 # a card without printed P/T that becomes a creature is 0/0 before counters
368
+ attrs[:toughness] ||= 0
369
+ end
370
+
371
+ # Layer 4 (CR 613.1d, 205.1b): "becomes a [subtype] in addition to its other types" until end of turn.
372
+ def added_subtypes(obj, effects)
373
+ effects.flat_map { it["kind"] == "add_subtypes" && it["affected"].include?(obj.id) ? it["subtypes"].map(&:to_sym) : [] }
374
+ end
375
+ end
376
+
377
+ include Characteristics
378
+ end
379
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Class levels (CR 717.2b): a designation of a permanent, kept in its memory ("class_level"), not copiable,
6
+ # and lost when it leaves the battlefield (CR 400.7). Without one a permanent is level 1 (CR 717.2d).
7
+ module ClassLevels
8
+ NO_LEVELS = [].freeze
9
+
10
+ def class_level(obj) = obj.memory.fetch("class_level", 1)
11
+
12
+ def set_class_level!(obj, level)
13
+ obj.memory["class_level"] = level
14
+ emit Events::ClassLevelChanged.new(object: obj.id, level: level)
15
+ end
16
+
17
+ # The class level bars whose abilities apply: level N or greater (CR 717.2a).
18
+ def active_class_levels(obj)
19
+ level = obj.memory["class_level"] or return NO_LEVELS
20
+ catalog.fetch(obj.card).ability_instances.select { it.ability.is_a?(Mechanics::Abilities::ClassLevel) && level >= it.ability.level }
21
+ end
22
+ end
23
+ include ClassLevels
24
+ end
25
+ end