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,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Damage, life and counters (CR 120, 119, 122), and fighting (CR 701.14).
6
+ module Damage
7
+ # CR 120.3: results of damage depend on what receives it. `combat`: combat damage (CR 510.2).
8
+ # Returns the damage dealt. With `lifelink: false` the caller applies lifelink itself
9
+ # (simultaneous combat damage: one life gain event per source, CR 120.3f, 119.9).
10
+ def deal_damage!(source, ref, amount, combat: false, lifelink: true)
11
+ return 0 unless amount.positive?
12
+
13
+ data = propose("damage", { "source" => source, "target" => ref, "amount" => amount, "combat" => combat }).data
14
+ dealt = deal_proposed_damage!(source, data, combat, lifelink)
15
+ run_follow_ups!(data) # the rest of a prevention effect (CR 615.5)
16
+ dealt
17
+ end
18
+
19
+ # The damage event after replacement and prevention effects (CR 614, 615).
20
+ def deal_proposed_damage!(source, data, combat, lifelink)
21
+ amount = data["amount"]
22
+ return 0 unless amount.positive? # prevented (CR 615)
23
+
24
+ ref = data["target"] # redirected (CR 614.9)
25
+
26
+ has = %i[deathtouch lifelink wither].to_h { [it, damage_source_keyword?(source, it)] }
27
+ index = Mechanics::Filters.player_index(self, ref)
28
+ if index
29
+ emit Events::DamageDealt.new(source: source, target: ref, amount: amount, combat: combat)
30
+ change_life!(index, -amount) # CR 120.3a
31
+ else
32
+ return 0 unless state.battlefield.include?(ref)
33
+
34
+ obj = object(ref)
35
+ defn = chars(obj)
36
+ emit Events::DamageDealt.new(source: source, target: ref, amount: amount, combat: combat)
37
+ if defn.creature?
38
+ mark_creature_damage!(obj, source, has[:wither], amount)
39
+ obj.deathtouched = true if has[:deathtouch] # CR 702.2b
40
+ end
41
+ remove_counters!(obj, "loyalty", amount) if defn.planeswalker? # CR 120.3c
42
+ end
43
+ lifelink!(source, amount) if lifelink && has[:lifelink]
44
+ amount
45
+ end
46
+
47
+ # CR 120.3d-e: damage to a creature is marked on it; from a source with wither it's -1/-1 counters
48
+ # put by the source's controller instead (CR 702.80a).
49
+ def mark_creature_damage!(obj, source, wither, amount)
50
+ return obj.damage += amount unless wither
51
+
52
+ add_counters!(obj, "-1/-1", amount, by_player: damage_source_controller(source))
53
+ end
54
+
55
+ # CR 120.6, 120.4a: damage that would be lethal to the creature `id` from `source`, counting damage already
56
+ # marked on it (1 if the source has deathtouch, CR 702.2c); a planeswalker's is its loyalty (CR 120.10).
57
+ # Nil for a player or an object that is neither.
58
+ def lethal_damage(source, id)
59
+ obj = state.objects[id]
60
+ return nil unless obj && state.battlefield.include?(id)
61
+ return loyalty(obj) if !chars(obj).creature? && chars(obj).planeswalker?
62
+ return nil unless chars(obj).creature?
63
+
64
+ lethal = [toughness(obj) - obj.damage, 0].max
65
+ damage_source_keyword?(source, :deathtouch) ? [lethal, 1].min : lethal
66
+ end
67
+
68
+ # CR 120.4a, 120.10: the excess damage of `amount` dealt to `id`, given its lethal damage before.
69
+ def excess_damage(lethal, amount) = lethal ? [amount - lethal, 0].max : 0
70
+
71
+ # Damage dealt at the same time (combat damage, CR 510.2) is one event for prevention effects
72
+ # that apply once per event (a shield counter, CR 122.1c). Nil outside such a batch.
73
+ attr_reader :damage_batch
74
+
75
+ def simultaneous_damage
76
+ @damage_batch = []
77
+ yield
78
+ ensure
79
+ @damage_batch = nil
80
+ end
81
+
82
+ # Whether a damage source has `keyword` (deathtouch, lifelink, wither): a card (a permanent or a spell) as it
83
+ # is now; a source no longer in the zone it's expected to be in, as it last existed (CR 702.2e, 702.15c,
84
+ # 702.80b).
85
+ def damage_source_keyword?(source, keyword)
86
+ obj = state.objects[source]
87
+ return obj.kind == "card" && chars(obj).keyword?(keyword) if obj
88
+
89
+ lki = last_known(source) || resolving_last_known(source) || resolving_source_lki(source)
90
+ Array(lki&.[]("keywords")).include?(keyword.to_s)
91
+ end
92
+
93
+ # The controller of a damage source, or as it last existed (CR 702.15b, 702.80a).
94
+ def damage_source_controller(source)
95
+ state.objects[source]&.controller ||
96
+ (last_known(source) || resolving_last_known(source) || resolving_source_lki(source))&.[]("controller")
97
+ end
98
+
99
+ # What the resolving ability remembers of its source, if `source` is that source (CR 608.2h).
100
+ def resolving_source_lki(source)
101
+ frame = state.frames.rfind { it["type"] == "resolve" }
102
+ ability = frame && state.objects[frame["object"]]
103
+ ability.memory&.[]("source_lki") if ability && !source.nil? && ability.source == source
104
+ end
105
+
106
+ # CR 702.15b: damage dealt by a source with lifelink makes its controller gain that much life.
107
+ def lifelink!(source, amount)
108
+ controller = damage_source_controller(source) or return
109
+ change_life!(controller, amount)
110
+ end
111
+
112
+ def change_life!(index, delta)
113
+ return if delta.zero?
114
+ return if delta.positive? && player_rule?(index, "cant_gain_life") # CR 119.7: not even replaced
115
+
116
+ delta = propose("life", { "player" => index, "delta" => delta }).data["delta"]
117
+ return if delta.zero?
118
+
119
+ pl = player(index)
120
+ before = pl.life
121
+ pl.life += delta
122
+ emit Events::LifeChanged.new(player: pl.name, from: before, to: pl.life)
123
+ end
124
+
125
+ # `by_player`: the player putting the counters (CR 122.6a; default the permanent's controller).
126
+ def add_counters!(object, kind, amount, by_player: nil)
127
+ return unless amount.positive?
128
+
129
+ data = { "object" => object.id, "counter" => kind, "amount" => amount, "player" => by_player || object.controller }
130
+ amount = propose("counters", data).data["amount"]
131
+ return unless amount.positive?
132
+
133
+ before = object.counters.fetch(kind, 0)
134
+ object.counters[kind] = before + amount
135
+ # CR 613.7c: keyword counters of this kind get the new counter's timestamp (Engine::Characteristics#ability_layers).
136
+ # The next timestamp isn't taken: an effect created later gets this number too and sorts after it.
137
+ (object.memory["counter_timestamps"] ||= {})[kind] = state.next_id if Mechanics::Counters.keyword?(kind)
138
+ emit Events::CountersChanged.new(object: object.id, counter: kind, from: before, to: before + amount)
139
+ end
140
+
141
+ # Counters on a player (CR 122.1): poison (ten or more lose the game, CR 122.1f, 704.5c), experience, rad, ...
142
+ def player_counters(index)
143
+ pl = player(index)
144
+ (pl.counters || {}).merge(pl.poison.positive? ? { "poison" => pl.poison } : {})
145
+ end
146
+
147
+ def add_player_counters!(index, kind, amount)
148
+ return unless amount.positive?
149
+
150
+ pl = player(index)
151
+ before = player_counters(index).fetch(kind, 0)
152
+ kind == "poison" ? pl.poison += amount : (pl.counters ||= {})[kind] = before + amount
153
+ emit Events::PlayerCountersChanged.new(player: pl.name, counter: kind, from: before, to: before + amount)
154
+ end
155
+
156
+ def remove_counters!(object, kind, amount)
157
+ before = object.counters.fetch(kind, 0)
158
+ after = [before - amount, 0].max
159
+ return if after == before
160
+
161
+ object.counters[kind] = after
162
+ emit Events::CountersChanged.new(object: object.id, counter: kind, from: before, to: after)
163
+ end
164
+
165
+ # ---- damage and counters --------------------------------------------------------------
166
+
167
+ # All damage marked on a permanent is removed ("healed", CR 120.6, 514.2).
168
+ def remove_damage!(obj)
169
+ obj.damage = 0
170
+ obj.deathtouched = false
171
+ end
172
+
173
+ def remove_all_counters!(obj, kind = nil)
174
+ (kind ? [kind] : obj.counters.keys).each { remove_counters!(obj, it, obj.counters.fetch(it, 0)) }
175
+ end
176
+
177
+ # CR 701.14a-d: each creature deals damage equal to its power to the other, at the same time; not
178
+ # combat damage. Neither deals damage unless both are still creatures on the battlefield (701.14b).
179
+ # A creature fighting itself deals twice its power to itself (701.14c). Returns the excess damage `one` dealt
180
+ # to `other` (CR 120.4a: "if excess damage is dealt to [it] this way"), 0 if they didn't fight.
181
+ def fight!(one, other)
182
+ return 0 unless [one, other].all? { state.battlefield.include?(it) && chars(it).creature? }
183
+
184
+ lethal = lethal_damage(one, other)
185
+ return excess_damage(lethal, deal_damage!(one, one, 2 * power(object(one)))) if one == other
186
+
187
+ powers = [power(object(one)), power(object(other))]
188
+ dealt = deal_damage!(one, other, powers[0])
189
+ deal_damage!(other, one, powers[1])
190
+ excess_damage(lethal, dealt)
191
+ end
192
+ end
193
+
194
+ include Damage
195
+ end
196
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Player designations that last for the rest of the game (PlayerState#designations), such as an enduring
6
+ # story (CR 702.195b). They have no rules meaning of their own; conditions read them
7
+ # (`{"designation": "enduring_story"}`, Mechanics::Condition::Designation).
8
+ module Designations
9
+ NAMES = %w[enduring_story].freeze
10
+
11
+ def designation?(index, designation) = player(index).designations.to_a.include?(designation)
12
+
13
+ def gain_designation!(index, designation)
14
+ return if designation?(index, designation)
15
+
16
+ (player(index).designations ||= []) << designation
17
+ emit Events::DesignationGained.new(player: name(index), designation: designation)
18
+ end
19
+
20
+ # Storied (CR 702.195a): "Any time you control three or more permanents that are artifacts, Sagas, and/or
21
+ # legendary and you don't have an enduring story, you have an enduring story for the rest of the game."
22
+ # Checked whenever state-based actions are, so before triggered abilities are put on the stack
23
+ # (CR 702.195c). Returns true when a player got one.
24
+ def tell_enduring_stories!
25
+ return false unless storied_card?
26
+
27
+ told = state.players.each_index.select do |i|
28
+ mine = state.battlefield.filter_map { (obj = object(it)).controller == i ? chars(obj) : nil }
29
+ !designation?(i, "enduring_story") && mine.any? { it.keyword?(:storied) } && mine.count { story_piece?(it) } >= 3
30
+ end
31
+ told.each { gain_designation!(it, "enduring_story") }
32
+ !told.empty?
33
+ end
34
+
35
+ private
36
+
37
+ def story_piece?(chars) = chars.type?(:artifact) || chars.legendary? || chars.subtypes.include?(:saga)
38
+
39
+ def storied_card?
40
+ @storied_card = !catalog.find { it.keyword?(:storied) }.nil? unless defined?(@storied_card)
41
+ @storied_card
42
+ end
43
+ end
44
+ include Designations
45
+ end
46
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Emblems (CR 114): objects in the command zone with abilities and no other characteristics. An emblem
6
+ # is a GameObject of kind "emblem" whose `card` is the card whose effect created it and whose `ability`
7
+ # is that effect's reference; its abilities are part of that card's data (Effects::CreateEmblem), so it
8
+ # needs no card of its own. It never leaves the command zone. Its abilities function there (CR 114.4):
9
+ # triggered abilities (Engine::Triggers#trigger_sources), static abilities (#static_sources) and
10
+ # player rules (#player_rule_statics).
11
+ module Emblems
12
+ # CR 114.1, 114.2: `index` gets an emblem; returns it.
13
+ def create_emblem!(index, card, ref)
14
+ obj = create_object(card, index, :command)
15
+ obj.kind = "emblem"
16
+ obj.ability = ref
17
+ state.command << obj.id
18
+ emit Events::EmblemCreated.new(player: name(index), object: obj.id, card: card)
19
+ obj
20
+ end
21
+
22
+ # CR 114.3: an emblem has no characteristics other than its abilities. They are referenced as granted
23
+ # abilities ("granted:<card>:<ref>:<j>"), so a triggered one resolves from the creating card's data.
24
+ def emblem_characteristics(obj)
25
+ abilities = catalog.fetch(obj.card).effect_granted(obj.ability).map do |inst|
26
+ AbilityInstance.new(ref: "granted:#{obj.card}:#{inst.ref}", ability: inst.ability)
27
+ end
28
+ Enjine::Characteristics::FACE_DOWN.with(name: "Emblem", abilities: abilities.freeze)
29
+ end
30
+
31
+ # The emblems in the command zone, as objects.
32
+ def emblems = (state.command || []).map { object(it) }
33
+ end
34
+
35
+ include Emblems
36
+ end
37
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # "As this enters" choices (CR 614.12), the status a permanent enters with (Engine#plan_move), and
6
+ # permanents a resolving effect puts onto the battlefield.
7
+ module Entering
8
+ # Counters from the caller ("with a +1/+1 counter", CR 701.72b) add to the ones replacements gave it.
9
+ def merge_enters!(status, extra)
10
+ extra.each do |key, value|
11
+ status[key] = key == "counters" ? status.fetch(key, {}).merge(value) { |_, a, b| a + b } : value
12
+ end
13
+ end
14
+
15
+ # The "as this enters" choices the controller must make before `id` enters (CR 614.12), in printed order
16
+ # (Multiversal Passage: a basic land type, then pay 2 life or enter tapped). `id` may also be an object (a
17
+ # stand-in), name a land played from outside the game, or a token an effect is about to create (see
18
+ # #token_key).
19
+ def entering_choices(id)
20
+ id = outside_object(nil, outside_card(id)) if outside_key?(id)
21
+ abilities = if id.is_a?(GameObject) || state.objects.key?(id) then chars(id).abilities
22
+ else token_to_create(id).printed_characteristics.abilities
23
+ end
24
+ abilities.map(&:ability).select { it.kind == :entering_choice }
25
+ end
26
+
27
+ def entering_choice(id) = entering_choices(id).first
28
+
29
+ # The answers given so far, one per choice in order (a single answer from an older snapshot counts as one).
30
+ def self.answers(given) = given.is_a?(Hash) ? [given] : Array(given)
31
+
32
+ # Whether `id` still has an "as this enters" choice to make, given the answers so far.
33
+ def entering_choice_pending?(id, given) = entering_choices(id).size > Entering.answers(given).size
34
+
35
+ # Records the answer to an enter_choice decision on the frame that asked, or, for a permanent an
36
+ # effect puts onto the battlefield, in that effect's locals (#ask_entering_choices!).
37
+ def enter_choice!(option)
38
+ answer = option.transform_keys(&:to_s)
39
+ # A choice of any number of permanents (devour): each pick asks again; "done" ends it (CR 614.12).
40
+ return ask!(state.pending.merge("chosen" => state.pending.fetch("chosen", []) + [answer["object"]])) if answer["type"] == "devour"
41
+
42
+ answer["objects"] = state.pending["chosen"] if state.pending.key?("chosen")
43
+ if state.pending["effect"]
44
+ answers = (answering_locals["entering"] ||= {})
45
+ id = state.pending["object"]
46
+ answers[id] = Entering.answers(answers[id]) + [answer.merge("player" => state.pending["player"])]
47
+ else
48
+ frame = state.frames.last
49
+ frame["enter"] = Entering.answers(frame["enter"]) + [answer]
50
+ end
51
+ ask!(nil)
52
+ end
53
+
54
+ def ask_enter_choice!(id, controller)
55
+ ask!("kind" => "enter_choice", "player" => controller, "object" => id)
56
+ :wait
57
+ end
58
+
59
+ def enters_from_answer(id, frame) = entering_statuses(id, Entering.answers(frame["enter"]))
60
+
61
+ # What a permanent enters with given its "as this enters" answers, combined (memories merged, counters
62
+ # added, tapped if any says so), or nil.
63
+ def entering_statuses(id, answers)
64
+ choices = entering_choices(id)
65
+ answers.each_with_index.reduce(nil) do |status, (answer, i)|
66
+ extra = choices[i] && entering_status(choices[i], answer)
67
+ next status || extra unless status && extra
68
+
69
+ status.merge(extra) do |key, mine, theirs|
70
+ next mine || theirs if key == "tapped"
71
+ next mine.merge(theirs) { |_, a, b| a + b } if key == "counters"
72
+
73
+ mine.is_a?(Hash) ? mine.merge(theirs) : theirs
74
+ end
75
+ end
76
+ end
77
+
78
+ # What a permanent enters with given one "as this enters" answer (a memory, tapped, a copy, CR 707.5).
79
+ def entering_status(choice, answer)
80
+ answer = answer.except("player").transform_keys(&:to_sym)
81
+ choice.respond_to?(:enters_with) ? choice.enters_with(self, answer) : choice.enters_for(answer)
82
+ end
83
+
84
+ # What the "as this enters" answers cost, paid as the permanent enters: life (CR 119.4), the card
85
+ # beheld (CR 701.4a), the permanents it devours (CR 702.82a).
86
+ def pay_entering_costs!(controller, frame)
87
+ choices = entering_choices(frame["object"])
88
+ Entering.answers(frame["enter"]).each_with_index do |answer, i|
89
+ choices[i].pay!(self, controller, answer) if choices[i].respond_to?(:pay!)
90
+ end
91
+ end
92
+
93
+ # ---- permanents an effect puts onto the battlefield (CR 614.12, 614.12a) ----------------
94
+
95
+ # Before any of them enters, the player who will control each permanent makes its "as this enters"
96
+ # choice, one permanent at a time. `entries`: [[object id or token key, controller], ...]. Answers
97
+ # stay in the effect's locals (Engine#enter_choice!), so the effect resumes after each. :wait while asking.
98
+ def ask_entering_choices!(locals, entries)
99
+ answers = locals["entering"] ||= {}
100
+ id, controller = entries.find { |ref, _| entering_choice_pending?(ref, answers[ref]) }
101
+ return nil unless id
102
+
103
+ token = state.objects.key?(id) ? {} : { "card" => token_to_create(id).id }
104
+ ask!({ "kind" => "enter_choice", "player" => controller, "object" => id, "effect" => true }.merge(token))
105
+ :wait
106
+ end
107
+
108
+ # Puts cards onto the battlefield at the same time: replacement effects for all of them are determined
109
+ # before any enters (CR 614.12), with the choices asked by #ask_entering_choices!. `entries`:
110
+ # [[id, controller, enters]]. Returns the new permanents.
111
+ def enter_battlefield!(locals, entries, reason:)
112
+ plans = entries.map do |id, controller, enters|
113
+ plan_move(id, :battlefield, reason: reason, controller: controller,
114
+ enters: with_entering_answer(locals, id, controller, enters))
115
+ end
116
+ plans.compact.map { execute_zone_change!(it) }
117
+ end
118
+
119
+ # The entering status `enters` plus what the "as this enters" answer for `id` adds (a memory, tapped);
120
+ # a life payment chosen is paid now, as it enters (CR 119.4).
121
+ def with_entering_answer(locals, id, controller, enters)
122
+ answers = locals.fetch("entering", {})[id] or return enters
123
+ pay_entering_costs!(controller, "enter" => answers, "object" => id)
124
+ extra = entering_statuses(id, Entering.answers(answers)) or return enters
125
+ (enters || {}).merge(extra) do |key, mine, theirs|
126
+ next mine || theirs if key == "tapped" # "enters tapped" from the effect stays
127
+
128
+ mine.is_a?(Hash) ? mine.merge(theirs) : theirs
129
+ end
130
+ end
131
+
132
+ # The options of a pending enter_choice: the first choice not answered yet. Several permanents entering at
133
+ # once can't be given choices whose combined life payments are more than the player has (CR 614.12b).
134
+ def entering_options(pending)
135
+ given = pending["effect"] ? answering_locals.fetch("entering", {})[pending["object"]] : state.frames.last["enter"]
136
+ choice = entering_choices(pending["object"])[Entering.answers(given).size]
137
+ return choice.pick_options(self, pending["player"], pending.fetch("chosen", [])) if choice.respond_to?(:pick_options)
138
+
139
+ options = choice.options(self, pending["player"])
140
+ return options unless pending["effect"]
141
+
142
+ spent = answering_locals.fetch("entering", {}).values.flat_map { Entering.answers(it) }.sum do
143
+ it["player"] == pending["player"] && it["type"] == "pay_life" ? it["life"] : 0
144
+ end
145
+ options.reject { it[:type] == "pay_life" && it[:life] > player(pending["player"]).life - spent }
146
+ end
147
+
148
+ # A token an effect is about to create, named before it exists ("new_token:<card>:<n>") so its "as
149
+ # this enters" choice can be asked first (CR 614.12, 111.1).
150
+ def token_key(definition, index) = "new_token:#{definition.id}:#{index}"
151
+ def token_to_create(key) = catalog.fetch(key.to_s.split(":")[1].to_s)
152
+ end
153
+
154
+ include Entering
155
+ end
156
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # The id prefix of a card exiled face down, so its id doesn't name it (CR 406.3).
6
+ FACE_DOWN_ID = "face_down"
7
+
8
+ # Cards exiled face down (CR 406.3, 406.3a-b). Such a card has no characteristics (Engine#chars) and
9
+ # only the players in its memory "may_look" see which card it is (View, Events::ZoneChanged). The effect
10
+ # that exiled it may let a player play it; the card is turned face up just before it's played, so
11
+ # whether it can be played is judged face up, and only for a player who may look at it.
12
+ module FaceDown
13
+ def face_down?(obj) = obj.zone == :exile && obj.memory["face_down"] == true
14
+
15
+ # Whether `index` may look at the face-down card (always true for a face-up card).
16
+ def may_look?(obj, index) = !face_down?(obj) || obj.memory.fetch("may_look", []).include?(index)
17
+
18
+ # Evaluates the block with the face-down card `id` seen face up (CR 406.3a: "turned face up just
19
+ # before the player announces that they are playing the card"). Nothing in the block changes state.
20
+ def face_up(id)
21
+ previous = @face_up
22
+ @face_up = id
23
+ yield
24
+ ensure
25
+ @face_up = previous
26
+ end
27
+ end
28
+ include FaceDown
29
+ end
30
+ end