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,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # What happened this turn and this game, for effects and conditions that look back ("if a
6
+ # creature died this turn", life a player lost this turn, how many times an ability resolved this
7
+ # turn).
8
+ #
9
+ # Everything is recorded in one place, from the events the engine already emits, before
10
+ # triggered abilities look at the event (so an intervening "if" sees it). A cast that is
11
+ # cancelled never emits SpellCast, so it never counts (CR 601.2i: a spell becomes cast only
12
+ # once casting is complete).
13
+ #
14
+ # Per player (PlayerState#turn_log, reset as each turn begins and kept as #last_turn_log for the turn after;
15
+ # #game_log for the whole game):
16
+ # "spells" card ids of spells the player cast "life_gained" / "life_lost" totals
17
+ # "spells_from" the zone each of those spells was cast from (CR 601.2a), in the same order
18
+ # "lands_played" [card id, zone it was played from] of the lands the player played (CR 305.1)
19
+ # "entered" [card id, types, subtypes] of permanents that entered under their control
20
+ # "died" [card id, types] of their permanents put into a graveyard from the battlefield
21
+ # "left" [card id, types, subtypes] of their permanents that left the battlefield (CR 400.7)
22
+ # "sacrificed" [card id, types, subtypes] of the permanents they sacrificed (CR 701.21a)
23
+ # "attackers" [card id, types, subtypes] of the creatures they attacked with (CR 508.1)
24
+ # "exiled" [card id, types] of their cards put into exile, from anywhere (as it last existed)
25
+ # "returned" permanents returned to their hand from the battlefield
26
+ # "drawn" / "discarded" how many cards they drew (CR 121.2) / discarded (CR 701.9a)
27
+ # "dilemmas" dilemmas they faced (CR 700.17)
28
+ # "noncombat_damage" noncombat damage dealt to them (CR 120.2b)
29
+ # "coin_flips" coins they flipped (CR 705)
30
+ # The game log also counts the turns each player has begun ("turns").
31
+ # Per object (GameObject#memory, stamped with the turn): "attacked" and "resolved" counts.
32
+ module TurnHistory
33
+ def record_history!(event)
34
+ case event
35
+ when Events::TurnBegan
36
+ turn_logs_rolled!
37
+ bump!(game_log(names.index(event.active)), "turns", 1) # "your third turn of the game"
38
+ when Events::SpellCast then cast_recorded!(event)
39
+ when Events::LifeChanged then life_recorded!(event)
40
+ when Events::ZoneChanged
41
+ zone_change_recorded!(event)
42
+ discard_recorded!(event) # mayhem (Engine::AltCasting)
43
+ when Events::DilemmaFaced then bump!(log_of(event.player), "dilemmas", 1)
44
+ when Events::AttackerDeclared then attack_recorded!(event)
45
+ when Events::AttacksDeclared # the attacking creatures' total power, per combat (Triggers::PlayerAttacks)
46
+ (log_of(event.player)["attack_powers"] ||= []) << event.attackers.sum { power(object(it)) }
47
+ when Events::Resolved then resolution_recorded!(event)
48
+ when Events::DamageDealt then damage_recorded!(event)
49
+ when Events::CoinFlipped then bump!(log_of(event.player), "coin_flips", 1) # CR 705.3 (Abilities::FirstFlipsWin)
50
+ end
51
+ end
52
+
53
+ # This turn's log of the player at `index`.
54
+ def turn_log(index) = player(index).turn_log ||= {}
55
+
56
+ # The previous turn's log of the player at `index` ("last turn", CR 500.1: whoever's turn it was).
57
+ def last_turn_log(index) = player(index).last_turn_log ||= {}
58
+
59
+ # This game's log of the player at `index`.
60
+ def game_log(index) = player(index).game_log ||= {}
61
+
62
+ # How many times this turn the ability `ref` of `source` has finished resolving (CR 608.2).
63
+ def times_resolved(source, ref)
64
+ memory = turned_memory(state.objects[source], "resolved")
65
+ memory ? memory.fetch(ref, 0) : 0
66
+ end
67
+
68
+ # Whether the permanent attacked this turn (CR 508.1).
69
+ def attacked_this_turn?(id) = attacks_this_turn(id).positive?
70
+
71
+ # How many times the permanent was declared as an attacker this turn ("for the first time each turn").
72
+ def attacks_this_turn(id) = turned_memory(state.objects[id], "attacked")&.fetch("count", 0).to_i
73
+
74
+ # An attack undone before the declaration was legal (CR 508.1c) never happened.
75
+ def attack_withdrawn!(id)
76
+ obj = state.objects[id]
77
+ bump!(object_memory!(id, "attacked"), "count", -1)
78
+ log = turn_log(obj.controller)["attackers"]
79
+ log&.delete_at(log.rindex { it.first == obj.card } || log.size)
80
+ end
81
+
82
+ # Whether the permanent entered the battlefield this turn (memory "entered", stamped as it enters).
83
+ def entered_this_turn?(obj) = obj.memory["entered"] == state.turn
84
+
85
+ # CR 608.2h: a permanent leaving the battlefield is remembered as it last existed by the abilities it
86
+ # is the source of (on the stack, being activated or waiting to be put on the stack: "source_lki" in
87
+ # their memory) and by the spell or ability resolving now ("its controller" of a destroyed target).
88
+ def remember_last_known!(id, lki)
89
+ info = last_known_memory(lki)
90
+ state.stack.each do |sid|
91
+ obj = object(sid)
92
+ obj.memory["source_lki"] = info.dup if obj.ability? && obj.source == id
93
+ end
94
+ state.pending_triggers.each { (it["memory"] ||= {})["source_lki"] = info.dup if it["source"] == id }
95
+ resolving = state.frames.rfind { it["type"] == "resolve" && it["ctx"] }
96
+ (resolving["ctx"]["lki"] ||= {})[id] = info if resolving
97
+ end
98
+
99
+ # A spell leaving the stack while a spell or ability resolves ("counter target spell. If the amount of mana
100
+ # spent to cast that spell was less than its mana value, ..."): the resolving one remembers it as it last
101
+ # existed on the stack (CR 608.2h): its controller, mana spent (CR 601.2h) and mana value, X included (CR 202.3e).
102
+ def remember_spell!(obj)
103
+ resolving = state.frames.rfind { it["type"] == "resolve" && it["ctx"] } or return
104
+ (resolving["ctx"]["lki"] ||= {})[obj.id] = { "controller" => obj.controller, "card" => obj.card,
105
+ "mana_spent" => obj.mana_spent&.dup, "mana_from" => obj.memory["mana_from"],
106
+ "mana_value" => Mechanics::Amount.mana_value(self, obj) }
107
+ end
108
+
109
+ # The part of last known information an ability keeps in its memory ("source_lki"): plain data.
110
+ def last_known_memory(lki)
111
+ info = { "controller" => lki["controller"], "power" => lki["power"], "toughness" => lki["toughness"],
112
+ "counters" => lki["counters"].dup, "card" => lki["card"], "keywords" => lki["keywords"].dup }
113
+ info["except"] = lki["except"] if lki["except"] # what a copy of it copies (CR 707.2, Engine::Copies)
114
+ info.merge(lki.slice(Mechanics::Linked::EXILED).transform_values(&:dup)) # CR 607.2a (Linked.exiled_with)
115
+ end
116
+
117
+ # What the resolving spell or ability remembers about an object that left the battlefield during it.
118
+ def resolving_last_known(id) = state.frames.rfind { it["type"] == "resolve" && it["ctx"] }&.dig("ctx", "lki", id)
119
+
120
+ # CR 702.171b: a permanent becomes saddled until the end of the turn; leaving the battlefield ends it (the
121
+ # card is a new object, CR 400.7), and it isn't copiable (it lives on the object, not its characteristics).
122
+ def saddle!(id) = object_memory!(id, "saddled")
123
+ def saddled?(obj) = obj.zone == :battlefield && !turned_memory(obj, "saddled").nil?
124
+
125
+ private
126
+
127
+ def log_of(player_name) = turn_log(names.index(player_name))
128
+
129
+ def turn_logs_rolled!
130
+ state.players.each do |pl|
131
+ pl.last_turn_log = pl.turn_log || {}
132
+ pl.turn_log = {}
133
+ end
134
+ end
135
+
136
+ def bump!(log, key, amount) = log[key] = log.fetch(key, 0) + amount
137
+
138
+ def cast_recorded!(event)
139
+ index = names.index(event.player)
140
+ (turn_log(index)["spells"] ||= []) << event.card
141
+ (turn_log(index)["spells_from"] ||= []) << ((spell = state.objects[event.object]) ? cast_origin(spell) : "hand")
142
+ bump!(game_log(index), "spells", 1)
143
+ end
144
+
145
+ def life_recorded!(event)
146
+ log = log_of(event.player)
147
+ bump!(log, "life_gained", event.gain) if event.gain.positive? # CR 119.9
148
+ bump!(log, "life_lost", event.loss) if event.loss.positive?
149
+ end
150
+
151
+ # CR 700.4: "dies" is battlefield to graveyard; the types are those it last had (CR 603.10a).
152
+ # Who it counts for: a permanent's controller as it last existed, or a card's owner.
153
+ def zone_change_recorded!(event)
154
+ entered_recorded!(event) if event.to == "battlefield"
155
+ land_play_recorded!(event) if event.reason == "play_land"
156
+ lki = event.from == "battlefield" ? last_known(event.old_id) : nil
157
+ departed_recorded!(event, lki) if lki
158
+ log = lki ? turn_log(lki["controller"]) : log_of(event.owner)
159
+ case event.reason
160
+ when "draw" then bump!(log_of(event.owner), "drawn", 1)
161
+ when "discard" then bump!(log_of(event.owner), "discarded", 1)
162
+ end
163
+ (log["exiled"] ||= []) << [event.card, (lki ? lki["types"] : printed_types(event.card)).map(&:to_s)] if event.to == "exile"
164
+ end
165
+
166
+ def entered_recorded!(event)
167
+ obj = state.objects[event.new_id] or return
168
+ (turn_log(obj.controller)["entered"] ||= []) << history_entry(event.card, chars(obj))
169
+ end
170
+
171
+ # CR 305.1: the player who plays a land controls it as it enters.
172
+ def land_play_recorded!(event)
173
+ index = state.objects[event.new_id]&.controller || names.index(event.owner)
174
+ (turn_log(index)["lands_played"] ||= []) << [event.card, event.from]
175
+ end
176
+
177
+ def departed_recorded!(event, lki)
178
+ log = turn_log(lki["controller"])
179
+ left = [event.card, lki["types"].map(&:to_s), lki.fetch("subtypes", []).map(&:to_s)]
180
+ (log["left"] ||= []) << left
181
+ (log["died"] ||= []) << left.first(2) if event.to == "graveyard"
182
+ (log["sacrificed"] ||= []) << left if event.reason == "sacrifice"
183
+ bump!(log_of(event.owner), "returned", 1) if event.to == "hand"
184
+ end
185
+
186
+ # CR 508.1: "you attacked this turn" / "attacked with a Spacecraft"; the attacker remembers it attacked.
187
+ def attack_recorded!(event)
188
+ obj = state.objects[event.object] or return
189
+ bump!(object_memory!(event.object, "attacked"), "count", 1)
190
+ (turn_log(obj.controller)["attackers"] ||= []) << history_entry(obj.card, chars(obj))
191
+ end
192
+
193
+ def history_entry(card, current) = [card, current.types.map(&:to_s), current.subtypes.map(&:to_s)]
194
+ def printed_types(card) = catalog.fetch(card).types
195
+
196
+ def damage_recorded!(event)
197
+ index = Mechanics::Filters.player_index(self, event.target)
198
+ bump!(turn_log(index), "noncombat_damage", event.amount) if index && !event.combat
199
+ end
200
+
201
+ def resolution_recorded!(event)
202
+ obj = state.objects[event.object]
203
+ return unless obj&.ability? && state.objects.key?(obj.source)
204
+
205
+ memory = object_memory!(obj.source, "resolved")
206
+ memory[obj.ability] = memory.fetch(obj.ability, 0) + 1
207
+ end
208
+
209
+ # A memory entry of the object that holds only this turn's data.
210
+ def object_memory!(id, key)
211
+ memory = state.objects.fetch(id).memory
212
+ entry = memory[key]
213
+ entry = memory[key] = { "turn" => state.turn } unless entry.is_a?(Hash) && entry["turn"] == state.turn
214
+ entry
215
+ end
216
+
217
+ def turned_memory(obj, key)
218
+ entry = obj&.memory&.[](key)
219
+ entry if entry.is_a?(Hash) && entry["turn"] == state.turn
220
+ end
221
+
222
+ # What a triggered ability remembers of its source: whether it was cast (CR 400.7d) and kicked (CR 702.33d),
223
+ # how it was cast, the mana spent to cast it (CR 601.2h) and, for a source that just left the battlefield
224
+ # ("when this leaves, put its counters on ..."), how it last existed (CR 603.10a, 608.2h).
225
+ def trigger_memory(source)
226
+ obj = state.objects[source["id"]]
227
+ source_memory = obj&.memory || {}
228
+ memory = source_memory["cast"] ? { "source_cast" => true } : {}
229
+ memory["kicked"] = true if source_memory["kicked"] # CR 702.33d: a kicked permanent's "if it was kicked"
230
+ memory.merge!(source_memory.slice("cast_from", "cast_with", "kicked_with")) # "if its evoke cost was paid" (Engine::AltCasting)
231
+ memory["source_mana_spent"] = obj.mana_spent.dup if obj&.mana_spent
232
+ memory["source_mana_from"] = Snapshot.deep_copy(source_memory["mana_from"]) if source_memory["mana_from"]
233
+ lki = last_known(source["id"])
234
+ memory["source_lki"] = last_known_memory(lki) if lki
235
+ memory
236
+ end
237
+ end
238
+ include TurnHistory
239
+ end
240
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Turn structure (CR 500): moving from step to step, the untap step's turn-based actions and the
6
+ # land-play allowance (CR 502, 305.2).
7
+ #
8
+ # Untap step (CR 502.1, 502.3): first, the permanents the active player controlled when they phased
9
+ # out phase in (unless an effect says permanents can't phase in); then the active player chooses
10
+ # which of their "may choose not to untap" permanents stay tapped (an `untap_choice` frame, one
11
+ # decision per such tapped permanent); then the rest of their permanents untap.
12
+ module TurnStructure
13
+ # ---- lands (CR 305.2) ---------------------------------------------------------------
14
+
15
+ # How many lands `index` can play this turn: one, plus one for each "you may play an additional
16
+ # land" ability of a permanent they control (CR 305.2). Lands put onto the battlefield by effects
17
+ # aren't played, so they don't count against it (CR 305.2a).
18
+ def land_plays_allowed(index)
19
+ 1 + state.battlefield.sum do |id|
20
+ obj = object(id)
21
+ obj.controller == index ? chars(obj).abilities.count { it.ability.kind == :additional_land_plays } : 0
22
+ end
23
+ end
24
+
25
+ # CR 305.2a-b: whether `index` may play another land this turn.
26
+ def land_play_available?(index) = player(index).lands_played < land_plays_allowed(index)
27
+
28
+ # ---- the untap step (CR 502) --------------------------------------------------------
29
+
30
+ def begin_untap_step!
31
+ index = state.active
32
+ phasing_step!(index) # CR 502.1
33
+ candidates = untap_choice_candidates(index)
34
+ return finish_untap_step!(index, []) if candidates.empty?
35
+
36
+ state.frames << { "type" => "untap_choice", "player" => index, "pending" => candidates, "keep" => [] }
37
+ ask!(nil)
38
+ end
39
+
40
+ # CR 502.3: tapped permanents of `index` whose "you may choose not to untap" applies, and that
41
+ # would otherwise untap.
42
+ def untap_choice_candidates(index)
43
+ state.battlefield.select do |id|
44
+ obj = object(id)
45
+ current = chars(obj)
46
+ obj.tapped && obj.controller == index && !current.keyword?(:doesnt_untap) && !obj.memory["exerted"] &&
47
+ current.abilities.any? { it.ability.kind == :may_not_untap }
48
+ end
49
+ end
50
+
51
+ # The untap_choice frame: one decision per candidate, then everything else untaps.
52
+ def step_untap_choice!(frame)
53
+ frame["pending"] &= state.battlefield
54
+ if (id = frame["pending"].first)
55
+ ask!("kind" => "untap_choice", "player" => frame["player"], "object" => id)
56
+ return :wait
57
+ end
58
+ finish_untap_step!(frame["player"], frame["keep"])
59
+ :done
60
+ end
61
+
62
+ # Records the answer to an untap_choice decision.
63
+ def choose_untap!(id, untap)
64
+ frame = state.frames.rfind { it["type"] == "untap_choice" }
65
+ frame["pending"].delete(id)
66
+ frame["keep"] << id unless untap
67
+ ask!(nil)
68
+ end
69
+
70
+ def finish_untap_step!(index, keep)
71
+ untap_all!(index, keep: keep)
72
+ turn_based_actions_done!
73
+ end
74
+
75
+ # ---- turn structure (CR 500) --------------------------------------------
76
+
77
+ def begin_turn!(index)
78
+ end_effects_until_turn_of!(index) # "until your next turn" (CR 611.2a)
79
+ state.turn += 1
80
+ state.active = index
81
+ state.players.each { it.lands_played = 0 }
82
+ state.extra_phases = State.no_extra_phases
83
+ emit Events::TurnBegan.new(turn: state.turn, active: name(index))
84
+ enter_step!(:untap)
85
+ end
86
+
87
+ def enter_step!(step_name)
88
+ state.extra_phases["combats"] += 1 if step_name == :beginning_of_combat
89
+ state.step = step_name
90
+ state.step_state = "begin"
91
+ state.passes = 0
92
+ state.priority = nil
93
+ end
94
+
95
+ # Runs the game forward until a decision is pending or the game is over.
96
+ def advance!
97
+ until state.pending || state.over?
98
+ if (frame = state.frames.last)
99
+ step_frame!(frame)
100
+ next
101
+ end
102
+ break if state.step.nil?
103
+
104
+ case state.step_state
105
+ when "begin"
106
+ state.step_state = "active"
107
+ emit Events::StepBegan.new(turn: state.turn, step: state.step.to_s, phase: step.phase.to_s)
108
+ step.begin(self)
109
+ when "done" then next_step!
110
+ else raise Error, "engine stalled in #{state.step} (#{state.step_state}) with no pending decision"
111
+ end
112
+ end
113
+ end
114
+
115
+ # Steps the innermost in-progress process (see Enjine::Frames).
116
+ def step_frame!(frame)
117
+ result = Frames.fetch(frame["type"]).step(self, frame)
118
+ state.frames.delete_at(state.frames.rindex { it.equal?(frame) }) if result == :done && state.frames.include?(frame)
119
+ end
120
+
121
+ # Called once a step's turn-based actions (and any decisions they needed) are complete.
122
+ def turn_based_actions_done!
123
+ step.finish(self)
124
+ if !step.priority? && state.step == :cleanup && !state.pending_triggers.empty?
125
+ state.repeat_cleanup = true # CR 514.3a: priority now, then another cleanup step
126
+ state.step_state = "active"
127
+ give_priority!(state.active)
128
+ elsif step.priority?
129
+ give_priority!(state.active)
130
+ else
131
+ ask!(nil)
132
+ state.step_state = "done"
133
+ end
134
+ end
135
+
136
+ # CR 500.4: mana empties between steps. CR 500.1: proceed to the next step / turn.
137
+ def next_step!
138
+ empty_mana_pools!
139
+ end_combat! if state.step == :end_of_combat # CR 511.3
140
+ return enter_step!(:combat_damage) if second_combat_damage_step? # CR 510.4
141
+
142
+ if state.step == :cleanup && state.repeat_cleanup
143
+ state.repeat_cleanup = false
144
+ return enter_step!(:cleanup)
145
+ end
146
+ index = Steps.index(state.step) + 1
147
+ index += 1 while index < Steps::ORDER.size && Steps::ORDER[index].skip?(self)
148
+ if index >= Steps::ORDER.size
149
+ begin_turn!(take_next_turn!) # an extra turn comes first (CR 500.7)
150
+ else
151
+ following = Steps::ORDER[index]
152
+ enter_step!(following.phase == step.phase ? following.name : phase_after!(step.phase, following.name))
153
+ end
154
+ end
155
+
156
+ # ---- additional phases (CR 500.8) ---------------------------------------------------
157
+
158
+ # The first step of each kind of phase an effect can add (an added main phase is a postcombat one).
159
+ ADDED_PHASES = { "combat" => :beginning_of_combat, "main" => :postcombat_main }.freeze
160
+
161
+ # "After this phase, there is an additional combat phase [followed by an additional main phase]": the
162
+ # phases are added directly after the current phase (CR 500.8).
163
+ # `attackers` (filter data): "only [these] creatures can attack during that combat phase" (CR 508.1c), the
164
+ # first added combat phase; the queue then holds {"phase": "combat", "attackers": filter} for it.
165
+ def add_phases!(phases, attackers: nil)
166
+ if attackers && (at = phases.index("combat"))
167
+ phases[at] = { "phase" => "combat", "attackers" => attackers }
168
+ end
169
+ state.extra_phases["pending"] << { "after" => step.phase.to_s, "phases" => phases }
170
+ end
171
+
172
+ # The attack restriction of the current added combat phase (CR 508.1c), as a filter; nil when none.
173
+ def combat_attack_filter
174
+ raw = state.extra_phases["attackers"] or return nil
175
+ (@attack_filters ||= {})[raw] ||= Mechanics.build_entry(:filter, raw, [], "attackers")
176
+ end
177
+
178
+ # As `phase` ends: the first step of the next added phase, else of the phase the turn goes on with
179
+ # (`natural`). Phases added after the same phase come most recently created first (CR 500.8).
180
+ def phase_after!(phase, natural)
181
+ extra = state.extra_phases
182
+ due = extra["pending"].select { it["after"] == phase.to_s }
183
+ extra["pending"] -= due
184
+ due.each { extra["queue"].unshift(*it["phases"]) }
185
+ extra.delete("attackers") # a restriction lasts for its combat phase only
186
+ if extra["queue"].empty?
187
+ resume = extra.delete("resume")
188
+ return resume ? resume.to_sym : natural
189
+ end
190
+ extra["resume"] ||= natural.to_s
191
+ added = extra["queue"].shift
192
+ return ADDED_PHASES.fetch(added) unless added.is_a?(Hash)
193
+
194
+ extra["attackers"] = added["attackers"]
195
+ ADDED_PHASES.fetch(added["phase"])
196
+ end
197
+
198
+ # ---- ending the turn (CR 725) -------------------------------------------------------
199
+
200
+ # CR 725.1: triggered abilities waiting to be put on the stack cease to exist (725.1a); every object on
201
+ # the stack is exiled, the resolving one included (725.1b); state-based actions are checked (725.1c);
202
+ # combat ends and the game skips to the cleanup step (725.1d), where abilities that triggered meanwhile
203
+ # go on the stack and another cleanup step follows (725.1f, 514.3a). The resolving spell or ability does
204
+ # nothing more (Engine::Resolution sees "turn_ended" on its frame).
205
+ def end_the_turn!
206
+ state.pending_triggers.clear
207
+ state.frames.each { it["turn_ended"] = true if it["type"] == "resolve" }
208
+ state.stack.dup.each do |id|
209
+ object(id).ability? ? remove_stack_object!(id) : move!(id, :exile, reason: "end_the_turn")
210
+ end
211
+ check_state_based_actions!
212
+ end_combat!
213
+ state.extra_phases = State.no_extra_phases.merge("combats" => state.extra_phases["combats"])
214
+ empty_mana_pools! # CR 500.4: the step ends
215
+ enter_step!(:cleanup)
216
+ end
217
+
218
+ # "If it's the first combat phase of the turn".
219
+ def first_combat_phase? = state.extra_phases["combats"] == 1
220
+ end
221
+
222
+ include TurnStructure
223
+ end
224
+ end