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,404 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # An independent rules checker for fuzzing. It watches a game's events and state
5
+ # and re-derives, from the raw data and the rules, what must be true after each
6
+ # decision, without calling the engine's own rules code. Any mismatch is a
7
+ # finding: the engine did something the rules don't allow.
8
+ #
9
+ # monitor = RulesMonitor.new(game)
10
+ # game.submit(option); monitor.check!(entry) # or use Fuzz.run(..., monitor: true)
11
+ # monitor.findings # => [] when everything matched
12
+ # monitor.stats # mechanics observed, e.g. {"counters_added" => 12, ...}
13
+ class RulesMonitor
14
+ UNLOCKABLE = %i[station_symbol max_speed].freeze # abilities whose own abilities apply only once unlocked
15
+ Finding = Data.define(:rule, :message, :turn, :step, :seq)
16
+
17
+ attr_reader :findings, :stats
18
+
19
+ def initialize(game)
20
+ @game = game
21
+ @findings = []
22
+ @stats = Hash.new(0)
23
+ @lands_this_turn = Hash.new(0)
24
+ @loyalty_activations = Hash.new(0)
25
+ end
26
+
27
+ def state = @game.state
28
+ def catalog = @game.catalog
29
+
30
+ # Checks one history entry (one decision and everything it caused).
31
+ def check!(entry)
32
+ @entry = entry
33
+ entry.events.each { observe(it) }
34
+ check_state!
35
+ @findings
36
+ end
37
+
38
+ private
39
+
40
+ def finding(rule, message)
41
+ @findings << Finding.new(rule: rule, message: message, turn: state.turn, step: state.step&.to_s, seq: @entry&.seq)
42
+ end
43
+
44
+ def defn(card) = catalog.fetch(card)
45
+
46
+ # A permanent's printed abilities, the abilities of its unlocked symbols in their place.
47
+ def abilities_of(obj)
48
+ return defn(obj.card).effect_granted(obj.ability).map(&:ability) if obj.kind == "emblem" # CR 114.3
49
+
50
+ defn(obj.card).abilities.reject { UNLOCKABLE.include?(it.kind) } + unlocked(obj).flat_map(&:nested_abilities)
51
+ end
52
+
53
+ # Its station symbols it has enough charge counters for (CR 722.2a), recomputed from the raw counters, and
54
+ # its max speed abilities while its controller's speed is 4 (CR 702.178a, 702.179e), and its class level
55
+ # bars up to its level (CR 717.2a).
56
+ def unlocked(obj)
57
+ charge = obj.counters.fetch("charge", 0)
58
+ fast = state.players[obj.controller].speed == 4
59
+ level = obj.memory.fetch("class_level", 1) # CR 717.2a, 717.2d
60
+ defn(obj.card).abilities.select do |ability|
61
+ case ability.kind
62
+ when :station_symbol then charge >= ability.threshold
63
+ when :max_speed then fast
64
+ else ability.is_a?(Mechanics::Abilities::ClassLevel) && level >= ability.level
65
+ end
66
+ end
67
+ end
68
+
69
+ # ---- per-event checks -------------------------------------------------------
70
+
71
+ def observe(event)
72
+ @stats[event.type] += 1
73
+ case event
74
+ when Events::TurnBegan then turn_began!
75
+ when Events::StepBegan then step_began!(event)
76
+ when Events::ZoneChanged then zone_changed!(event)
77
+ when Events::SpellCast then spell_cast!(event)
78
+ when Events::AbilityActivated then ability_activated!(event)
79
+ when Events::AttackerDeclared then attacker_declared!(event)
80
+ when Events::BlockerDeclared then blocker_declared!(event)
81
+ when Events::CountersChanged then @stats["counters_#{event.counter}"] += event.to - event.from
82
+ end
83
+ end
84
+
85
+ def turn_began!
86
+ @lands_this_turn.clear
87
+ @loyalty_activations.clear
88
+ # CR 514.2: after cleanup no damage is marked and no "until end of turn" effect remains.
89
+ state.battlefield.each do |id|
90
+ obj = state.objects[id]
91
+ next unless obj.damage.positive? && !keyword?(obj, :keeps_damage)
92
+
93
+ finding("514.2", "#{id} still has #{obj.damage} damage at the start of a turn")
94
+ end
95
+ return if state.turn == 1 || state.effects.none? { it["duration"] == "end_of_turn" }
96
+
97
+ finding("514.2", "an until-end-of-turn effect survived cleanup")
98
+ end
99
+
100
+ def step_began!(_event)
101
+ # CR 500.5: mana pools, restricted mana included, empty between steps.
102
+ state.players.each do |pl|
103
+ # CR 702.189a: "until end of combat" mana is still there as each combat step begins
104
+ next if pl.mana_pool.values.all?(&:zero?) && pl.restricted_mana.all? { Engine::ManaSources.mana_held?(it, state.step) }
105
+
106
+ finding("500.5", "#{pl.name}'s mana pool isn't empty at #{state.step}")
107
+ end
108
+ end
109
+
110
+ def zone_changed!(event)
111
+ land_played!(event) if event.reason == "play_land"
112
+ return unless event.reason == "resolve" && event.to == "graveyard" && defn(event.card).permanent?
113
+
114
+ finding("608.3", "permanent spell #{event.card} went to the graveyard on resolution")
115
+ end
116
+
117
+ # CR 305.1-305.2: whoever plays the land controls it (an exiled card of an opponent's may be played).
118
+ def land_played!(event)
119
+ land = state.objects[event.new_id]
120
+ player = land ? state.players[land.controller].name : event.owner
121
+ @lands_this_turn[player] += 1
122
+ allowed = 1 + additional_land_plays(state.players.index { it.name == player })
123
+ finding("305.2", "#{player} played #{@lands_this_turn[player]} lands this turn") if @lands_this_turn[player] > allowed
124
+ active = state.players[state.active].name
125
+ finding("305.1", "#{player} played a land on #{active}'s turn") if player != active
126
+ end
127
+
128
+ # CR 305.2: "you may play an additional land" abilities of the player's permanents, from the raw card data
129
+ # and from resolved effects that granted them (Effects::Become).
130
+ def additional_land_plays(index)
131
+ state.battlefield.sum do |id|
132
+ obj = state.objects[id]
133
+ next 0 unless obj.controller == index
134
+
135
+ printed = defn(obj.card).abilities.count { it.kind == :additional_land_plays }
136
+ granted = state.effects.select { it["kind"] == "abilities" && it["affected"].include?(id) }.sum do |effect|
137
+ defn(effect["card"]).effect_granted(effect["ref"]).count { it.ability.kind == :additional_land_plays }
138
+ end
139
+ printed + granted
140
+ end
141
+ end
142
+
143
+ # CR 307.1 / 301.1 etc.: sorcery-speed spells only in your main phase with an empty stack.
144
+ def spell_cast!(event)
145
+ card = defn(event.card)
146
+ @stats["cast:#{event.card}"] += 1
147
+ return if card.instant_speed? || event.during_resolution # CR 608.2g: timing doesn't apply
148
+
149
+ caster = event.player
150
+ return if sneaked?(event, caster)
151
+
152
+ # The stack was empty as casting began (CR 601.2a) when the spell is at its bottom; what went on it since
153
+ # ("whenever you cast" triggers, even one still waiting for its targets, CR 603.3d) doesn't count.
154
+ unless state.players[state.active].name == caster && %i[precombat_main postcombat_main].include?(state.step) &&
155
+ state.stack.last == event.object
156
+ finding("307.1", "#{caster} cast sorcery-speed #{event.card} at #{state.step} (stack #{state.stack.size})")
157
+ end
158
+ end
159
+
160
+ # CR 702.190a: a spell cast for its sneak cost, at instant speed during its caster's declare blockers step.
161
+ def sneaked?(event, caster)
162
+ state.objects[event.object]&.memory&.[]("cast_with") == "sneak" && state.step == :declare_blockers &&
163
+ state.players[state.active].name == caster
164
+ end
165
+
166
+ # Printed keywords plus those of unlocked symbols, keywords granted and those of statics. A permanent that
167
+ # loses all abilities (CR 613.1f) has only the keywords granted to it (timestamps are the engine's specs').
168
+ def keyword?(obj, keyword)
169
+ return granted_keywords(obj.id).include?(keyword.to_sym) if stripped?(obj)
170
+
171
+ defn(obj.card).keyword?(keyword) || unlocked(obj).any? { it.keywords.include?(keyword) } ||
172
+ granted_keywords(obj.id).include?(keyword.to_sym) || static_keyword?(obj, keyword) ||
173
+ (keyword.to_sym == :haste && obj.memory["haste_while_controlled_by"] == obj.controller) # cast with suspend, CR 702.62a
174
+ end
175
+
176
+ # Whether an effect or an applying static ability makes it lose all abilities (CR 613.1f).
177
+ def stripped?(obj)
178
+ return true if state.effects.any? { it["kind"] == "lose_abilities" && it["affected"].include?(obj.id) }
179
+
180
+ applying_statics(obj).any?(&:loses_abilities?)
181
+ end
182
+
183
+ # Permanents and emblems, whose static abilities function in the command zone (CR 114.4).
184
+ def static_holders = state.battlefield + (state.command || [])
185
+
186
+ # The static abilities of permanents on the battlefield and emblems that apply to `obj` now, matched on printed types.
187
+ def applying_statics(obj)
188
+ engine = @game.instance_variable_get(:@engine)
189
+ facts = facts(obj, defn(obj.card))
190
+ static_holders.flat_map do |id|
191
+ source = state.objects[id]
192
+ abilities_of(source).select do |ability|
193
+ ability.kind == :static && ability.affects?(obj, source, facts) && ability.active?(engine, source)
194
+ end
195
+ end
196
+ end
197
+
198
+ # Keywords granted until end of turn or by an attached Aura or Equipment (CR 301.5f, 303.4m).
199
+ def granted_keywords(id)
200
+ until_eot = state.effects.select { it["kind"] == "grant" && it["affected"].include?(id) }.flat_map { it["keywords"] }
201
+ (until_eot + attached_grants(id).flat_map { it["keywords"] }).map(&:to_sym)
202
+ end
203
+
204
+ def ability_activated!(event)
205
+ source = defn(event.card)
206
+ ability = source.ability(event.ability)
207
+ return unless ability&.kind == :loyalty
208
+
209
+ @loyalty_activations[event.source] += 1
210
+ if @loyalty_activations[event.source] > 1
211
+ finding("606.3",
212
+ "#{event.source} activated #{@loyalty_activations[event.source]} loyalty abilities this turn")
213
+ end
214
+ changes = @entry.events.grep(Events::CountersChanged).select { it.object == event.source && it.counter == "loyalty" }
215
+ paid = changes.sum { it.to - it.from }
216
+ return unless paid != ability.loyalty_cost && state.objects.key?(event.source) && changes.any?
217
+
218
+ finding("606.4",
219
+ "#{event.source} loyalty changed by #{paid}, cost was #{ability.loyalty_cost}")
220
+ end
221
+
222
+ # CR 302.6 / 508.1a: attackers were untapped and not summoning sick (or have haste).
223
+ def attacker_declared!(event)
224
+ obj = state.objects[event.object]
225
+ return unless obj
226
+
227
+ active_turn = state.turn
228
+ sick = obj.controlled_since.nil? || obj.controlled_since >= active_turn
229
+ finding("302.6", "summoning-sick #{event.object} attacked") if sick && !keyword?(obj, :haste)
230
+ return unless keyword?(obj, :defender) && !keyword?(obj, :attacks_despite_defender)
231
+
232
+ finding("702.3b", "#{event.object} has defender but attacked")
233
+ end
234
+
235
+ # A keyword from an applying static ability of a permanent (CR 604.2), matched on printed types.
236
+ def static_keyword?(obj, keyword) = applying_statics(obj).any? { it.keywords.include?(keyword) }
237
+
238
+ # CR 702.9b: a creature with flying can be blocked only by creatures with flying or reach.
239
+ def blocker_declared!(event)
240
+ blocker = state.objects[event.object]
241
+ attacker = state.objects[event.attacker]
242
+ return unless blocker && attacker && keyword?(attacker, :flying)
243
+ return if keyword?(blocker, :flying) || keyword?(blocker, :reach)
244
+
245
+ finding("702.9b", "#{event.object} without flying or reach blocked flying #{event.attacker}")
246
+ end
247
+
248
+ # ---- state checks after each decision ------------------------------------------
249
+
250
+ def check_state!
251
+ check_power_toughness!
252
+ check_damage_life!
253
+ check_sba! if @game.pending_decision&.kind == "priority"
254
+ check_hand_size!
255
+ end
256
+
257
+ # CR 613.4, recomputed independently: printed (or a 7b setting effect) + counters + active pump effects
258
+ # + static ability bonuses. Characteristic-defining power/toughness (7a) is left to the engine's specs.
259
+ def check_power_toughness!
260
+ return if state.battlefield.any? { stripped?(state.objects[it]) } # CR 613.1f, 613.8a: the engine's specs
261
+
262
+ view = @game.view_for(:observer)["battlefield"].to_h { [it["id"], it] }
263
+ statics = static_pumps
264
+ state.battlefield.each do |id|
265
+ obj = state.objects[id]
266
+ card = defn(obj.card)
267
+ next unless recomputable?(obj, card)
268
+
269
+ net = obj.counters.fetch("+1/+1", 0) - obj.counters.fetch("-1/-1", 0)
270
+ pumps = state.effects.select { it["kind"] == "pump" && it["affected"].include?(id) } + attached_grants(id) +
271
+ static_bonuses(obj, card, statics)
272
+ base = base_power_toughness(obj, card)
273
+ expected = [base[0] + net + pumps.sum { it["power"] }, base[1] + net + pumps.sum { it["toughness"] }]
274
+ actual = view.fetch(id).values_at("power", "toughness")
275
+ finding("613.4c", "#{id} is #{actual.join("/")}, expected #{expected.join("/")}") if actual != expected
276
+ end
277
+ end
278
+
279
+ # A creature whose power/toughness isn't characteristic-defining (7a) or set by a static ability (7b).
280
+ # One whose creature types an effect replaced (assimilate, "becomes a Borg", layer 4) is left to the engine's
281
+ # specs: which statics apply to it depends on those types.
282
+ def recomputable?(obj, card)
283
+ card.creature? && !card.defined_pt? && applying_statics(obj).none?(&:base_power_toughness) && !types_replaced?(obj)
284
+ end
285
+
286
+ # Creature types an effect replaced (layer 4): the monitor matches statics against printed types.
287
+ def types_replaced?(obj)
288
+ state.effects.any? { %w[creature_types assimilate].include?(it["kind"]) && Array(it["affected"]).include?(obj.id) }
289
+ end
290
+
291
+ # CR 613.4b: the latest setting effect, else as printed, or as a copy's exceptions or a token's creating
292
+ # effect set them (CR 707.9b, 707.2: "copy_except").
293
+ def base_power_toughness(obj, card)
294
+ setting = state.effects.select { it["kind"] == "base_power_toughness" && it["affected"].include?(obj.id) }.last
295
+ return setting.values_at("power", "toughness") if setting
296
+
297
+ except = obj.memory["copy_effects"]&.last&.[]("except") || obj.memory["copy_except"] || {}
298
+ [except.fetch("power", card.power), except.fetch("toughness", card.toughness)]
299
+ end
300
+
301
+ # Static abilities printed on permanents that give a bonus to more than the attached permanent and
302
+ # apply now: [[source object, ability, [power, toughness]], ...]. Conditions and amounts are the
303
+ # primitives' own; which creatures get the bonus is decided here from printed types and raw counters.
304
+ def static_pumps
305
+ engine = @game.instance_variable_get(:@engine)
306
+ static_holders.flat_map do |id|
307
+ source = state.objects[id]
308
+ abilities_of(source).filter_map do |ability|
309
+ next unless ability.kind == :static && ability.pump? && !ability.attached? && ability.active?(engine, source)
310
+
311
+ [source, ability, ability.bonus(engine, source)]
312
+ end
313
+ end
314
+ end
315
+
316
+ def static_bonuses(obj, card, statics)
317
+ facts = facts(obj, card)
318
+ statics.filter_map do |source, ability, (power, toughness)|
319
+ { "power" => power, "toughness" => toughness } if ability.affects?(obj, source, facts)
320
+ end
321
+ end
322
+
323
+ # What statics match a permanent on: its printed characteristics, every creature type for a changeling
324
+ # (CR 702.73a) and the keywords it was granted (a bonus to creatures "with flying", CR 702.1).
325
+ def facts(obj, card)
326
+ chars = Mechanics::Keywords::Changeling.apply(card.printed_characteristics)
327
+ chars = chars.with(keywords: chars.keywords | granted_keywords(obj.id))
328
+ # A crewed Vehicle is an artifact creature (CR 702.122b): the types an effect adds count.
329
+ added = state.effects.select { it["kind"] == "add_types" && it["affected"].include?(obj.id) }.flat_map { it["types"] }
330
+ chars = chars.with(types: chars.types | added.map(&:to_sym)) unless added.empty?
331
+ Mechanics::Filters::Facts.new(chars: chars, counters: obj.counters, token: card.token?)
332
+ end
333
+
334
+ # "Equipped/enchanted creature gets/has ..." of every permanent attached to `id` whose condition holds
335
+ # ("during your turn", CR 604.2): [{"power" => n, "toughness" => n, "keywords" => [...]}].
336
+ def attached_grants(id)
337
+ engine = @game.instance_variable_get(:@engine)
338
+ state.battlefield.filter_map { state.objects[it] if state.objects[it].attached_to == id }.flat_map do |att|
339
+ abilities_of(att).filter_map do |ability|
340
+ next unless ability.kind == :static && ability.attached? && ability.active?(engine, att)
341
+
342
+ power, toughness = ability.bonus(engine, att)
343
+ { "power" => power, "toughness" => toughness, "keywords" => ability.keywords }
344
+ end
345
+ end
346
+ end
347
+
348
+ # CR 120.3a: damage to a player causes that much life loss.
349
+ def check_damage_life!
350
+ events = @entry.events
351
+ events.grep(Events::DamageDealt).each do |dmg|
352
+ next unless dmg.target.start_with?("player:")
353
+
354
+ @stats["damage_to_players"] += dmg.amount
355
+ name = dmg.target.delete_prefix("player:")
356
+ losses = events.grep(Events::LifeChanged).select { it.player == name && it.to < it.from }.sum { it.from - it.to }
357
+ finding("120.3a", "#{name} took #{dmg.amount} damage but lost #{losses} life") if losses < dmg.amount
358
+ end
359
+ end
360
+
361
+ # CR 704.5f/g/i: no creature with lethal damage or 0 toughness, no planeswalker with 0 loyalty, when a player has priority.
362
+ def check_sba!
363
+ view = @game.view_for(:observer)["battlefield"]
364
+ view.each do |perm|
365
+ lethal = perm.key?("toughness") && perm["damage"].positive? && perm["damage"] >= perm["toughness"] &&
366
+ !indestructible?(state.objects[perm["id"]]) # CR 702.12b: lethal damage doesn't destroy it
367
+ if perm.key?("toughness") && (perm["toughness"] <= 0 || lethal)
368
+ finding("704.5g", "#{perm["id"]} survived with #{perm["damage"]} damage and toughness #{perm["toughness"]}")
369
+ end
370
+ finding("704.5i", "#{perm["id"]} survived with 0 loyalty") if perm.key?("loyalty") && perm["loyalty"] <= 0
371
+ end
372
+ end
373
+
374
+ # With replaced creature types, a static keyed on them may grant it.
375
+ def indestructible?(obj) = keyword?(obj, :indestructible) || types_replaced?(obj)
376
+
377
+ # CR 514.1: after cleanup the active player has at most seven cards (checked at the next turn's priority).
378
+ # The player whose turn ended is the one active before (an extra turn may follow its own, CR 500.7).
379
+ def check_hand_size!
380
+ previous_name = @active_name
381
+ @active_name = state.players[state.active].name if state.active
382
+ return unless previous_name && @entry.events.any?(Events::TurnBegan)
383
+
384
+ previous = state.players.find { it.name == previous_name }
385
+ return if no_max_hand_size?(previous_name)
386
+
387
+ finding("514.1", "#{previous.name} ended their turn with #{previous.hand.size} cards") if previous.hand.size > 7
388
+ end
389
+
390
+ # CR 402.2: an effect or a permanent's ability may give a player no maximum hand size.
391
+ def no_max_hand_size?(name)
392
+ ref = "player:#{name}"
393
+ return true if state.effects.any? { it["rule"] == "no_max_hand_size" && it["affected"].include?(ref) }
394
+
395
+ index = state.players.index { it.name == name }
396
+ state.battlefield.any? do |id|
397
+ obj = state.objects[id]
398
+ defn(obj.card).abilities.any? do |ability|
399
+ ability.kind == :player_rule && ability.rule == "no_max_hand_size" && ability.applies_to?(index, obj.controller)
400
+ end
401
+ end
402
+ end
403
+ end
404
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # A frozen, compiled pairing of two ready players. Compile once, spawn many games:
5
+ #
6
+ # setup = Enjine::Setup.new(alice, bob)
7
+ # game = setup.new_game(seed: 42)
8
+ #
9
+ # The catalog and decklists are captured at compile time and shared by every game,
10
+ # so later deck edits never affect an existing Setup.
11
+ class Setup
12
+ attr_reader :players, :catalog, :decklists, :outside
13
+
14
+ def initialize(*players)
15
+ players = players.flatten
16
+ raise ArgumentError, "a game needs exactly 2 players" unless players.size == 2
17
+ raise ArgumentError, "player names must differ" if players.map(&:name).uniq.size != 2
18
+
19
+ players.each { |p| raise PlayerNotReady.new(p.deck.errors, name: p.name) unless p.ready? }
20
+
21
+ @players = players.freeze
22
+ @catalog = Catalog.new
23
+ players.each { |p| @catalog.merge(p.deck.catalog) }
24
+ @catalog.freeze
25
+ @decklists = players.map { |p| p.deck.card_ids.freeze }.freeze
26
+ @outside = players.map { |p| p.deck.outside_ids.freeze }.freeze # CR 400.11a: the cards they own outside the game
27
+ freeze
28
+ end
29
+
30
+ def names = @players.map(&:name)
31
+
32
+ def new_game(seed:)
33
+ Game.start(catalog: @catalog, names: names, decklists: @decklists, seed: seed, outside: @outside).tap do |game|
34
+ @players.each { |p| p.seat(game) }
35
+ end
36
+ end
37
+
38
+ # Resumes a game from a snapshot produced by Game#export.
39
+ def import(snapshot)
40
+ Game.import(snapshot, catalog: @catalog).tap do |game|
41
+ @players.each { |p| p.seat(game) if game.names.include?(p.name) }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # Snapshot validation of object ids and of player-level lists (cards outside the game, restricted mana).
5
+ module Snapshot
6
+ module_function
7
+
8
+ # The id names its card ("card#n"): its own card for a copy (Engine::Copies), none if face down (CR 406.3),
9
+ # the card (not the face up) for a card with faces ("alfa/1" is "alfa#n", CardDefinition::Faces).
10
+ def validate_object_id(obj, path, catalog, next_id, errors)
11
+ id = obj["id"]
12
+ match = /\A([a-z0-9_]+)#(\d+)\z/.match(id.to_s)
13
+ own = (obj["memory"].is_a?(Hash) && obj["memory"]["own_card"]) || obj["card"]
14
+ face_down = match && match[1] == Engine::FACE_DOWN_ID && obj["zone"].to_s == "exile" && obj.dig("memory", "face_down") == true
15
+ named = own.to_s.split(CardDefinition::FACE_SEPARATOR).first
16
+ errors << "#{path}.id: malformed #{id.inspect}" unless match && (match[1] == named || face_down)
17
+ errors << "#{path}.memory.own_card: #{own.inspect} is not in the catalog" unless catalog.include?(own.to_s)
18
+ errors << "#{path}.id: #{id} is not below next_id #{next_id}" if match && match[2].to_i >= next_id.to_i
19
+ errors << "#{path}.card: #{obj["card"].inspect} is not in the catalog" unless catalog.include?(obj["card"].to_s)
20
+ end
21
+
22
+ # Cards outside the game (CR 400.11a): definition ids only (they aren't objects, CR 400.11), each in the
23
+ # catalog; absent in older snapshots. `outside_lists` holds what each player started with.
24
+ def validate_outside(data, catalog, errors)
25
+ check = lambda do |list, path|
26
+ next errors << "#{path}: must be a list of card ids" unless list.is_a?(Array)
27
+
28
+ list.each_with_index do |id, j|
29
+ errors << "#{path}[#{j}]: #{id.inspect} is not in the catalog" unless id.is_a?(String) && catalog[id]&.id == id
30
+ end
31
+ end
32
+ lists = data.fetch("outside_lists", [[], []])
33
+ shaped = lists.is_a?(Array) && lists.size == 2
34
+ errors << "outside_lists: must be a list of 2 card id lists" unless shaped
35
+ lists.each_with_index { |list, i| check.call(list, "outside_lists[#{i}]") } if shaped
36
+ Array(data["players"]).each_with_index do |pl, i|
37
+ check.call(pl.fetch("outside", []), "players[#{i}].outside") if pl.is_a?(Hash)
38
+ end
39
+ end
40
+
41
+ # Pool mana kept apart: restricted (CR 106.6) and/or kept until end of combat (CR 702.189a):
42
+ # [{"mana" => "C", "spend_only" => {...}, "until" => "end_of_combat"}]; absent in older snapshots.
43
+ def validate_restricted_mana(list, errors, path)
44
+ return errors << "#{path}: must be a list" unless list.is_a?(Array)
45
+
46
+ list.each_with_index do |entry, i|
47
+ ok = entry.is_a?(Hash) && (entry.keys - %w[mana spend_only until from]).empty? && Mana::TYPES.include?(entry["mana"].to_s.to_sym) &&
48
+ [nil, "end_of_combat"].include?(entry["until"]) && (entry.key?("spend_only") || entry.key?("until")) &&
49
+ (!entry.key?("from") || valid_mana_source?(entry["from"]))
50
+ problems = []
51
+ Mechanics::SpendOnly.build(entry["spend_only"], problems, "#{path}[#{i}].spend_only") if ok && entry.key?("spend_only")
52
+ unless ok
53
+ errors << "#{path}[#{i}]: must be {\"mana\": W/U/B/R/G/C, \"spend_only\": restriction, \"until\": \"end_of_combat\", " \
54
+ "\"from\": source}"
55
+ end
56
+ errors.concat(problems)
57
+ end
58
+ end
59
+
60
+ # The remembered sources of pool mana: one per mana at most (Engine::ManaSources#add_mana!).
61
+ def validate_pool_sources(player, errors, path)
62
+ list = player.fetch("pool_sources", [])
63
+ pool = player["mana_pool"].is_a?(Hash) ? player["mana_pool"] : {}
64
+ ok = list.is_a?(Array) && list.all? do |entry|
65
+ entry.is_a?(Hash) && entry.keys.sort == %w[from mana] && Mana::TYPES.include?(entry["mana"].to_s.to_sym) &&
66
+ valid_mana_source?(entry["from"])
67
+ end
68
+ ok &&= list.map { it["mana"] }.tally.all? { |type, n| n <= pool.fetch(type, 0).to_i }
69
+ errors << "#{path}: must list {\"mana\": type, \"from\": source}, at most one per mana in the pool" unless ok
70
+ end
71
+
72
+ # A mana source's remembered characteristics (Engine::ManaSources#mana_provenance).
73
+ def valid_mana_source?(from)
74
+ from.is_a?(Hash) && from.keys.sort == %w[colors name subtypes supertypes types] && from["name"].is_a?(String) &&
75
+ %w[colors subtypes supertypes types].all? { from[it].is_a?(Array) && from[it].all?(String) }
76
+ end
77
+
78
+ # Player designations (Engine::Designations): distinct known names; absent in older snapshots.
79
+ def validate_designations(list, errors, path)
80
+ known = Engine::Designations::NAMES
81
+ return if list.is_a?(Array) && list.uniq.size == list.size && list.all? { known.include?(it) }
82
+
83
+ errors << "#{path}: must be a list of distinct designations (#{known.join(", ")})"
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # Snapshot validation of the turn, the pending decision, delayed triggers and extra turns.
5
+ module Snapshot
6
+ module_function
7
+
8
+ # CR 603.7: each delayed triggered ability names a card in the catalog and one of its delayed abilities.
9
+ def validate_delayed_triggers(data, catalog, errors)
10
+ list = data.fetch("delayed_triggers", [])
11
+ return errors << "delayed_triggers: must be a list" unless list.is_a?(Array)
12
+
13
+ list.each_with_index do |entry, i|
14
+ path = "delayed_triggers[#{i}]"
15
+ next errors << "#{path}: must be an object" unless entry.is_a?(Hash)
16
+
17
+ errors << "#{path}.controller: must be 0 or 1" unless [0, 1].include?(entry["controller"])
18
+ errors << "#{path}.results: must be a list" unless entry.fetch("results", []).is_a?(Array)
19
+ card = catalog.include?(entry["card"].to_s) && catalog.fetch(entry["card"])
20
+ next if card && card.ability(entry["ability"].to_s).respond_to?(:trigger)
21
+
22
+ errors << "#{path}: #{entry["card"].inspect} has no delayed ability #{entry["ability"].inspect}"
23
+ end
24
+ end
25
+
26
+ # CR 500.7: extra turns still to be taken (the last one first) and the players who began one.
27
+ def validate_extra_turns(data, errors)
28
+ extra = data.fetch("extra_turns", { "queue" => [], "begun" => [] })
29
+ ok = extra.is_a?(Hash) && (extra.keys - ["skip"]).sort == %w[begun queue] && # "skip": turns to skip (CR 614.10)
30
+ extra.values.all? { |list| list.is_a?(Array) && list.all? { [0, 1].include?(it) } }
31
+ errors << "extra_turns: must be {\"queue\": [player index...], \"begun\": [...], \"skip\": [...] (optional)}" unless ok
32
+ end
33
+
34
+ # CR 500.8: phases added this turn (State.no_extra_phases).
35
+ def validate_extra_phases(data, errors)
36
+ extra = data.fetch("extra_phases", State.no_extra_phases)
37
+ ok = extra.is_a?(Hash) && (extra.keys - %w[pending queue resume combats attackers]).empty? && added_phases?(extra["queue"]) &&
38
+ (extra["attackers"].nil? || attack_filter?(extra["attackers"])) &&
39
+ extra["combats"].is_a?(Integer) && !extra["combats"].negative? &&
40
+ (extra["resume"].nil? || Steps::BY_NAME.key?(extra["resume"].to_s.to_sym)) &&
41
+ extra["pending"].is_a?(Array) && extra["pending"].all? { pending_phases?(it) }
42
+ return if ok
43
+
44
+ errors << "extra_phases: must be {\"pending\": [{\"after\": phase, \"phases\": [...]}], \"queue\": " \
45
+ "[combat|main|{\"phase\": \"combat\", \"attackers\": filter}...], \"resume\": step, \"combats\": n, " \
46
+ "\"attackers\": filter (optional)}"
47
+ end
48
+
49
+ # An added phase: "combat" | "main", or a combat phase only some creatures can attack in (CR 508.1c).
50
+ def added_phases?(list)
51
+ list.is_a?(Array) && list.all? do |phase|
52
+ Engine::ADDED_PHASES.key?(phase) ||
53
+ (phase.is_a?(Hash) && phase.keys.sort == %w[attackers phase] && phase["phase"] == "combat" && attack_filter?(phase["attackers"]))
54
+ end
55
+ end
56
+
57
+ def attack_filter?(data) = [].tap { Mechanics.build_entry(:filter, data, it, "attackers") }.empty?
58
+
59
+ def pending_phases?(entry)
60
+ entry.is_a?(Hash) && entry.keys.sort == %w[after phases] && added_phases?(entry["phases"]) &&
61
+ Steps::ORDER.any? { it.phase.to_s == entry["after"] }
62
+ end
63
+
64
+ def validate_turn(data, errors)
65
+ errors << "step_state: must be one of #{State::STEP_STATES.join(", ")}" unless State::STEP_STATES.include?(data["step_state"])
66
+ step = data["step"]
67
+ errors << "step: unknown step #{step.inspect}" unless step.nil? || Steps::BY_NAME.key?(step.to_sym)
68
+ if step
69
+ errors << "active: must be 0 or 1" unless [0, 1].include?(data["active"])
70
+ errors << "turn: must be a positive integer" unless data["turn"].is_a?(Integer) && data["turn"].positive?
71
+ errors << "starting_player: must be 0 or 1" unless [0, 1].include?(data["starting_player"])
72
+ end
73
+ validate_pending(data, errors)
74
+ end
75
+
76
+ def validate_pending(data, errors)
77
+ pending = data["pending"]
78
+ if data["result"]
79
+ errors << "pending: must be empty once the game is over" if pending
80
+ return
81
+ end
82
+ return errors << "pending: a game in progress needs a pending decision" unless pending.is_a?(Hash)
83
+
84
+ errors << "pending.kind: unknown decision #{pending["kind"].inspect}" unless Decisions::KINDS.key?(pending["kind"])
85
+ errors << "pending.player: must be 0 or 1" unless [0, 1].include?(pending["player"])
86
+ return unless pending["kind"] == "priority" && pending["player"] != data["priority"]
87
+
88
+ errors << "pending: priority decision for player #{pending["player"]} but priority is #{data["priority"].inspect}"
89
+ end
90
+ end
91
+ end