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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # Plays random games choosing uniformly among legal options and checks the
5
+ # engine's invariants after every decision. A crash, a stall, a broken invariant
6
+ # or an export/import mismatch raises Fuzz::Failure with the seed to reproduce it.
7
+ module Fuzz
8
+ # Raised when a fuzzed game breaks; carries the reproduction data. `kind`: :finding (the rules monitor
9
+ # disagreed), :stall (the decision cap was hit), :error (an exception, a broken invariant or roundtrip).
10
+ class Failure < Error
11
+ attr_reader :seed, :decisions, :kind
12
+
13
+ def initialize(message, seed:, decisions:, kind: :error)
14
+ @seed = seed
15
+ @decisions = decisions
16
+ @kind = kind
17
+ super("#{message} (seed #{seed}, after #{decisions} decisions)")
18
+ end
19
+ end
20
+
21
+ Result = Data.define(:games, :decisions, :turns, :outcomes, :casts, :seconds)
22
+
23
+ module_function
24
+
25
+ # A random legal deck of `size` cards drawn uniformly from `cards` (card data blocks), whatever their colors,
26
+ # plus `outside` cards its player owns outside the game (CR 400.11a; the copy limit counts both, 100.4a).
27
+ def random_deck(cards, rng:, size: Deck::MIN_SIZE, outside: 0)
28
+ definitions = cards.map { CardDefinition.from_h(it) }
29
+ counts = Hash.new(0)
30
+ extra = Hash.new(0)
31
+ draw(definitions, counts, rng, size) { counts[it] += 1 }
32
+ draw(definitions, counts, rng, outside) do |id|
33
+ counts[id] += 1
34
+ extra[id] += 1
35
+ end
36
+ doc = { "cards" => definitions.select { counts.key?(it.id) }.map(&:to_h),
37
+ "main" => counts.filter_map { |id, n| { "card" => id, "count" => n - extra[id] } if n > extra[id] } }
38
+ doc["outside"] = extra.map { |id, n| { "card" => id, "count" => n } } unless extra.empty?
39
+ doc
40
+ end
41
+
42
+ # Picks `number` random cards that still fit the four-copy rule, yielding each one's id.
43
+ def draw(definitions, counts, rng, number)
44
+ number.times do
45
+ defn = definitions[rng.rand(definitions.size)]
46
+ defn = definitions[rng.rand(definitions.size)] until defn.deck_limit.nil? || counts[defn.id] < defn.deck_limit
47
+ yield defn.id
48
+ end
49
+ end
50
+
51
+ # roundtrip_every: export+import and compare every N decisions (0 disables).
52
+ def run(setup, games:, seed: 0, max_decisions: 20_000, roundtrip_every: 0, invariants: true, monitor: true)
53
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
54
+ totals = { decisions: 0, turns: 0, outcomes: Hash.new(0), casts: Hash.new(0) }
55
+ games.times do |i|
56
+ game_seed = seed + i
57
+ game = setup.new_game(seed: game_seed)
58
+ game.subscribe { |e| totals[:casts][e.card] += 1 if e.is_a?(Events::SpellCast) || e.is_a?(Events::AbilityActivated) }
59
+ watcher = monitor ? RulesMonitor.new(game) : nil
60
+ decisions = play(game, Agents::Random.new(seed: game_seed), game_seed, max_decisions, roundtrip_every,
61
+ invariants, watcher)
62
+ totals[:decisions] += decisions
63
+ totals[:turns] += game.turn
64
+ totals[:outcomes][game.result["reason"]] += 1
65
+ end
66
+ Result.new(games: games, decisions: totals[:decisions], turns: totals[:turns],
67
+ outcomes: totals[:outcomes].to_h,
68
+ casts: totals[:casts].sort_by do |card, n|
69
+ [-n, card]
70
+ end.to_h, seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
71
+ end
72
+
73
+ def play(game, agent, seed, max_decisions, roundtrip_every, invariants, watcher = nil)
74
+ count = 0
75
+ until game.over?
76
+ raise Failure.new("game exceeded #{max_decisions} decisions", seed: seed, decisions: count, kind: :stall) if count >= max_decisions
77
+
78
+ begin
79
+ entry = game.submit(agent.choose(game.pending_decision))
80
+ rescue Failure
81
+ raise
82
+ rescue StandardError => e
83
+ raise Failure.new("#{e.class}: #{e.message}", seed: seed, decisions: count)
84
+ end
85
+ count += 1
86
+ check!(game, seed, count) if invariants
87
+ watch!(watcher, entry, seed, count) if watcher
88
+ roundtrip!(game, seed, count) if roundtrip_every.positive? && (count % roundtrip_every).zero?
89
+ end
90
+ count
91
+ end
92
+
93
+ def watch!(watcher, entry, seed, count)
94
+ found = watcher.check!(entry)
95
+ return if found.empty?
96
+
97
+ first = found.first
98
+ raise Failure.new("rules finding CR #{first.rule}: #{first.message}", seed: seed, decisions: count, kind: :finding)
99
+ end
100
+
101
+ def check!(game, seed, count)
102
+ errors = Snapshot.validate(game.state.to_h, game.catalog)
103
+ raise Failure.new("invariant broken: #{errors.join("; ")}", seed: seed, decisions: count) unless errors.empty?
104
+ end
105
+
106
+ def roundtrip!(game, seed, count)
107
+ copy = Game.import(game.export, catalog: game.catalog)
108
+ same = copy.state.to_h == game.state.to_h && copy.pending_decision&.options == game.pending_decision&.options
109
+ raise Failure.new("export/import roundtrip changed the game", seed: seed, decisions: count) unless same
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Enjine
6
+ # The public face of one game.
7
+ #
8
+ # decision = game.pending_decision # => Decision(player:, kind:, options:)
9
+ # game.submit(decision.options.first)
10
+ #
11
+ # An illegal submission raises and never changes the game; `apply` and `import` are all or nothing.
12
+ class Game
13
+ # The decision the engine is waiting for.
14
+ Decision = Data.define(:player, :kind, :options, :details) do
15
+ def to_h
16
+ { "player" => player, "kind" => kind, "options" => options.map { it.transform_keys(&:to_s) } }
17
+ .merge(details)
18
+ end
19
+ end
20
+
21
+ attr_reader :catalog, :history
22
+
23
+ def self.start(catalog:, names:, decklists:, seed:, outside: nil)
24
+ new(catalog, State.initial(names: names, decklists: decklists, seed: seed, outside: outside)).tap(&:start!)
25
+ end
26
+
27
+ # Resumes a game from #export output (a JSON String or the parsed Hash).
28
+ def self.import(snapshot, catalog:)
29
+ data = snapshot.is_a?(String) ? parse_json(snapshot) : Snapshot.deep_stringify(snapshot)
30
+ raise InvalidSnapshot, ["snapshot must contain \"state\""] unless data.is_a?(Hash) && data["state"]
31
+
32
+ state, history = load_parts(data, catalog)
33
+ game = new(catalog, state, history)
34
+ verify_pending!(game) # engine errors here are bugs and propagate unchanged
35
+ game
36
+ end
37
+
38
+ def self.load_parts(data, catalog)
39
+ [Snapshot.load(data["state"], catalog), Array(data["history"]).map { HistoryEntry.from_h(it) }]
40
+ rescue ArgumentError, KeyError, NoMethodError, TypeError => e
41
+ raise InvalidSnapshot, ["malformed snapshot: #{e.message}"]
42
+ end
43
+
44
+ def self.parse_json(text)
45
+ JSON.parse(text)
46
+ rescue JSON::ParserError => e
47
+ raise InvalidSnapshot, ["not valid JSON: #{e.message}"]
48
+ end
49
+
50
+ def self.verify_pending!(game)
51
+ return if game.over? || !game.pending_decision.options.empty?
52
+
53
+ raise InvalidSnapshot, ["pending #{game.state.pending["kind"]} decision has no legal options"]
54
+ end
55
+ private_class_method :parse_json, :verify_pending!, :load_parts
56
+
57
+ def initialize(catalog, state, history = [])
58
+ @version = 0
59
+ @catalog = catalog
60
+ @history = history
61
+ @subscribers = []
62
+ @buffer = []
63
+ @engine = Engine.new(catalog, state) { |event| @buffer << event }
64
+ end
65
+
66
+ # @api private
67
+ def start!
68
+ record(nil, nil) { @engine.start! }
69
+ end
70
+
71
+ def state = @engine.state
72
+ def names = @engine.names
73
+ def over? = state.over?
74
+ def result = state.result&.dup
75
+ def turn = state.turn
76
+ def step = state.step
77
+
78
+ def pending_decision
79
+ pending = state.pending
80
+ return nil unless pending
81
+
82
+ @pending_decision = nil if @pending_for != @version
83
+ @pending_for = @version
84
+ @pending_decision ||= Decision.new(
85
+ player: @engine.name(pending["player"]), kind: pending["kind"], options: @engine.options.freeze,
86
+ details: pending.except("kind", "player")
87
+ )
88
+ end
89
+
90
+ def legal_options = pending_decision&.options || []
91
+
92
+ # Submits one option for the pending decision. Returns the HistoryEntry it produced.
93
+ def submit(option)
94
+ decision = pending_decision
95
+ raise IllegalDecision, "the game is over" unless decision
96
+
97
+ chosen = normalize(option)
98
+ unless decision.options.include?(chosen)
99
+ raise IllegalDecision.new("#{chosen.inspect} is not a legal option for #{decision.kind}",
100
+ option: chosen, legal_options: decision.options)
101
+ end
102
+ record(decision.player, decision.kind, chosen) { @engine.submit!(chosen) }
103
+ end
104
+
105
+ # Fast-forwards through a list of options, all or nothing.
106
+ def apply(options)
107
+ transaction do
108
+ options.each_with_index do |option, index|
109
+ submit(option)
110
+ rescue IllegalDecision => e
111
+ raise IllegalDecision.new(e.message, option: e.option, legal_options: e.legal_options, index: index)
112
+ end
113
+ end
114
+ self
115
+ end
116
+
117
+ # CR 104.3a: a player may concede at any time.
118
+ def concede(player)
119
+ raise IllegalDecision, "the game is over" if over?
120
+
121
+ index = player_index(player)
122
+ record(names[index], "concede", { type: "concede" }) { @engine.concede!(index) }
123
+ end
124
+
125
+ # Starts this game over from its opening, with the same or a new seed.
126
+ def restart(seed: nil)
127
+ fresh = State.initial(names: names, decklists: state.decklists, seed: seed.nil? ? state.seed : seed,
128
+ outside: state.outside_lists)
129
+ @engine.state = fresh
130
+ @history = []
131
+ @buffer.clear
132
+ start!
133
+ self
134
+ end
135
+
136
+ def view_for(viewer) = View.build(@engine, viewer_key(viewer))
137
+ def history_for(viewer) = @history.map { it.for_viewer(viewer_key(viewer)) }
138
+
139
+ # Ids of every decision submitted so far: with the seed this replays the game.
140
+ def decision_log = @history.reject { it.option.nil? || it.kind == "concede" }.map { it.option.transform_keys(&:to_s) }
141
+
142
+ def subscribe(&block)
143
+ @subscribers << block
144
+ self
145
+ end
146
+
147
+ def to_h = { "state" => state.to_h, "history" => @history.map(&:to_h) }
148
+
149
+ # An independent copy of this game (state and history) for search or lookahead.
150
+ # Subscribers are not copied.
151
+ def fork = Game.new(@catalog, state.fork, @history.dup)
152
+ def export = JSON.generate(to_h)
153
+
154
+ private
155
+
156
+ def record(player, kind, option = nil)
157
+ @version = @version.to_i + 1
158
+ @buffer.clear
159
+ before = [state.turn, state.step&.to_s]
160
+ yield
161
+ entry = HistoryEntry.new(seq: @history.size + 1, turn: before[0], step: before[1], player: player,
162
+ kind: kind, option: option, events: @buffer.dup.freeze)
163
+ @history << entry
164
+ deliver(entry.events) unless @in_transaction
165
+ entry
166
+ end
167
+
168
+ def transaction
169
+ saved = state.fork
170
+ history_size = @history.size
171
+ @in_transaction = true
172
+ yield
173
+ @in_transaction = false
174
+ deliver(@history[history_size..].flat_map(&:events))
175
+ rescue StandardError
176
+ @in_transaction = false
177
+ @engine.state = saved
178
+ @version += 1
179
+ @history = @history.first(history_size)
180
+ raise
181
+ end
182
+
183
+ def deliver(events)
184
+ events.each { |event| @subscribers.each { it.call(event) } }
185
+ end
186
+
187
+ def normalize(option)
188
+ raise IllegalDecision, "an option must be a Hash, got #{option.class}" unless option.is_a?(Hash)
189
+
190
+ option.to_h { |k, v| [k.to_sym, v.is_a?(Symbol) ? v.to_s : v] }
191
+ end
192
+
193
+ def player_index(player)
194
+ names.index(player.respond_to?(:name) ? player.name : player) or
195
+ raise ArgumentError, "unknown player #{player.inspect}"
196
+ end
197
+
198
+ def viewer_key(viewer)
199
+ return :observer if viewer.to_s == "observer"
200
+
201
+ names[player_index(viewer)]
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # One entry of a game's history: the decision a player made (nil for the game's
5
+ # opening) and every event it caused.
6
+ HistoryEntry = Data.define(:seq, :turn, :step, :player, :kind, :option, :events) do
7
+ def self.from_h(hash)
8
+ h = hash.transform_keys(&:to_s)
9
+ new(seq: h["seq"], turn: h["turn"], step: h["step"], player: h["player"], kind: h["kind"],
10
+ option: h["option"]&.transform_keys(&:to_sym), events: h["events"].map { Events.from_h(it) })
11
+ end
12
+
13
+ def to_h = for_viewer(:observer)
14
+
15
+ # What `viewer` (a player name or :observer) may know about this entry.
16
+ def for_viewer(viewer)
17
+ shown = if option.nil? || viewer == :observer || viewer == player || !Decisions::KINDS.key?(kind)
18
+ option # concessions (and other non-decision entries) are public
19
+ else
20
+ Decisions.fetch(kind).redact(option, false)
21
+ end
22
+ { "seq" => seq, "turn" => turn, "step" => step, "player" => player, "kind" => kind,
23
+ "option" => shown&.transform_keys(&:to_s), "events" => events.map { it.for_viewer(viewer) } }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # Mana symbols, colors and mana costs (CR 106, 107.4, 202).
5
+ module Mana
6
+ COLORS = %i[W U B R G].freeze
7
+ # Every type of mana that can sit in a mana pool: five colors plus colorless (CR 106.1).
8
+ TYPES = (COLORS + [:C]).freeze
9
+ SYMBOL = /\{([^{}]*)\}/
10
+
11
+ def self.empty_pool
12
+ TYPES.to_h { |t| [t, 0] }
13
+ end
14
+
15
+ # A parsed mana cost such as "{2}{R}{R}" or "{X}{R}". `x_symbols` counts the {X} (CR 107.3);
16
+ # their value is announced while casting (CR 601.2b) and is 0 everywhere else (CR 107.3g).
17
+ # `hybrid` lists the hybrid symbols ({R/G}), each as the colors that can pay it (CR 107.4e).
18
+ Cost = Data.define(:generic, :colored, :x_symbols, :hybrid) do
19
+ def initialize(generic:, colored:, x_symbols: 0, hybrid: [].freeze) = super
20
+
21
+ def self.parse(text)
22
+ errors = []
23
+ cost = Mana.parse_cost(text, errors)
24
+ raise ArgumentError, errors.join(", ") unless errors.empty?
25
+
26
+ cost
27
+ end
28
+
29
+ # Mana value (CR 202.3); {X} counts as 0 off the stack (CR 202.3e). A hybrid symbol counts its
30
+ # largest component (CR 202.3f): 1 for {R/G}, 2 for {2/B}.
31
+ def mana_value = generic + colored.values.sum + hybrid.sum { Mana.mono_hybrid?(it) ? 2 : 1 }
32
+
33
+ # The cost with X replaced by `value` (CR 107.3a): each {X} adds that much generic mana.
34
+ def with_x(value) = with(generic: generic + (x_symbols * value), x_symbols: 0)
35
+
36
+ # The colors of the cost's colored and hybrid symbols, in WUBRG order (CR 202.2, 202.2d, 107.4e).
37
+ def colors = COLORS.select { |c| colored.key?(c) || hybrid.any? { it.include?(c) } }
38
+
39
+ # One entry per colored or two-color hybrid symbol: the mana types that can pay it (CR 601.2h). A
40
+ # monocolored hybrid symbol has no single requirement; see #equivalents.
41
+ def requirements = colored.flat_map { |color, n| Array.new(n) { [color] } } + hybrid.reject { Mana.mono_hybrid?(it) }
42
+
43
+ # The costs without monocolored hybrid symbols this cost can be paid as (CR 601.2b, 107.4e): each {2/B}
44
+ # as {B} or as {2}. Just itself when it has none.
45
+ def equivalents
46
+ mono, rest = hybrid.partition { Mana.mono_hybrid?(it) }
47
+ return [self] if mono.empty?
48
+
49
+ [true, false].repeated_permutation(mono.size).map do |as_color|
50
+ paid = mono.zip(as_color).filter_map { |symbol, color| symbol.last if color }
51
+ with(generic: generic + (2 * (mono.size - paid.size)), hybrid: rest,
52
+ colored: colored.merge(paid.tally) { |_, a, b| a + b })
53
+ end
54
+ end
55
+
56
+ def to_s
57
+ parts = ["{X}"] * x_symbols
58
+ parts << "{#{generic}}" if generic.positive? || (colored.empty? && hybrid.empty? && x_symbols.zero?)
59
+ hybrid.each { parts << "{#{it.join("/")}}" }
60
+ colored.each { |c, n| parts.concat(["{#{c}}"] * n) }
61
+ parts.join
62
+ end
63
+ end
64
+
65
+ HYBRID = %r{\A([WUBRG2])/([WUBRG])\z}
66
+
67
+ # A monocolored hybrid symbol such as {2/B} (CR 107.4e); its first component is the generic "2".
68
+ def self.mono_hybrid?(symbol) = symbol.first.to_s == "2"
69
+
70
+ # The sum of several costs (an ability's mana costs, a spell's mana cost plus a kicker cost).
71
+ def self.sum_costs(costs)
72
+ colored = Hash.new(0)
73
+ costs.each { |cost| cost.colored.each { |c, n| colored[c] += n } }
74
+ Cost.new(generic: costs.sum(&:generic), colored: colored.to_h.freeze, x_symbols: costs.sum(&:x_symbols),
75
+ hybrid: costs.flat_map(&:hybrid).freeze)
76
+ end
77
+
78
+ # Parses a mana cost string; pushes problems into errors and returns a Cost (or nil).
79
+ def self.parse_cost(text, errors, path = "mana_cost")
80
+ unless text.is_a?(String)
81
+ errors << "#{path}: must be a string like \"{1}{G}\""
82
+ return nil
83
+ end
84
+
85
+ rest = text.gsub(SYMBOL, "")
86
+ unless rest.empty?
87
+ errors << "#{path}: unexpected characters in #{text.inspect}"
88
+ return nil
89
+ end
90
+
91
+ generic = 0
92
+ x = 0
93
+ colored = {}
94
+ hybrid = []
95
+ text.scan(SYMBOL).flatten.each do |sym|
96
+ case sym
97
+ when /\A\d+\z/ then generic += sym.to_i
98
+ when "X" then x += 1
99
+ when "W", "U", "B", "R", "G", "C" then colored[sym.to_sym] = colored.fetch(sym.to_sym, 0) + 1
100
+ when HYBRID then hybrid << hybrid_symbol(sym, errors, path)
101
+ else errors << "#{path}: unsupported mana symbol {#{sym}}"
102
+ end
103
+ end
104
+ Cost.new(generic: generic, colored: colored.freeze, x_symbols: x, hybrid: hybrid.compact.freeze)
105
+ end
106
+
107
+ # A hybrid symbol (CR 107.4e), kept in its printed order: two-color such as {R/G} ([:R, :G]) or
108
+ # monocolored such as {2/B} ([:"2", :B]: one black mana or two mana of any type). Phyrexian symbols
109
+ # aren't supported (they are "unsupported mana symbol"s).
110
+ def self.hybrid_symbol(sym, errors, path)
111
+ pair = HYBRID.match(sym).captures.map(&:to_sym)
112
+ return pair.freeze if pair.uniq.size == 2
113
+
114
+ errors << "#{path}: {#{sym}} needs two different colors"
115
+ nil
116
+ end
117
+
118
+ # Pool types as mana symbols: [:G, :G] -> "{G}{G}".
119
+ def self.symbols(types) = types.map { "{#{it}}" }.join
120
+
121
+ # Parses produced mana such as "{G}" or "{C}{C}" into an Array of pool types.
122
+ def self.parse_produce(text, errors, path)
123
+ unless text.is_a?(String) && !text.empty?
124
+ errors << "#{path}: must be a string like \"{G}\""
125
+ return nil
126
+ end
127
+ unless text.gsub(SYMBOL, "").empty?
128
+ errors << "#{path}: unexpected characters in #{text.inspect}"
129
+ return nil
130
+ end
131
+
132
+ symbols = text.scan(SYMBOL).flatten
133
+ bad = symbols.reject { |s| TYPES.include?(s.to_sym) }
134
+ unless bad.empty?
135
+ errors << "#{path}: unsupported produced mana #{bad.map { "{#{it}}" }.join}"
136
+ return nil
137
+ end
138
+
139
+ symbols.map(&:to_sym).freeze
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # "If [cause] causes a triggered ability of a permanent you control to trigger, that ability triggers
7
+ # an additional time" (CR 603.2d). A static ability; the engine asks it each time a triggered ability
8
+ # of a permanent triggers (Engine#additional_triggers). Delayed and reflexive triggered abilities are
9
+ # never affected.
10
+ class AdditionalTriggers < Primitive
11
+ CAUSES = %w[you_gain_life].freeze
12
+
13
+ primitive :ability, :additional_triggers,
14
+ description: "While this permanent is on the battlefield, a triggered ability of a permanent its " \
15
+ "controller controls that triggers because of `cause` triggers an additional time. " \
16
+ "`cause`: you_gain_life (its controller gaining life).",
17
+ rules: %w[603.2d 119.9],
18
+ params: { "cause" => CAUSES.join(" | ") },
19
+ example: { "additional_triggers" => { "cause" => "you_gain_life" } }
20
+
21
+ attr_reader :cause
22
+
23
+ def self.build(params, errors, path, **)
24
+ Mechanics.check_keys(params, %w[cause], errors, path)
25
+ cause = Mechanics.fetch_param(params, :cause)
26
+ errors << "#{path}.cause: must be one of #{CAUSES.join(", ")}" unless CAUSES.include?(cause)
27
+ new(cause)
28
+ end
29
+
30
+ def initialize(cause)
31
+ super()
32
+ @cause = cause
33
+ freeze
34
+ end
35
+
36
+ def kind = :additional_triggers
37
+ def mana_ability? = false
38
+ def allowed_types = nil
39
+ def try_effects = []
40
+ def to_s = "triggered abilities of your permanents that trigger from you gaining life trigger an additional time"
41
+
42
+ # `holder`: the permanent with this ability; `source`: the triggering ability's source (a permanent).
43
+ def additional_trigger?(engine, event, holder, source)
44
+ return false unless source["controller"] == holder.controller
45
+
46
+ event.is_a?(Events::LifeChanged) && event.gain.positive? && engine.names.index(event.player) == holder.controller
47
+ end
48
+ end
49
+ end
50
+
51
+ module Effects
52
+ # "For as long as [this] remains tapped, if a triggered ability of that creature triggers, that ability
53
+ # triggers an additional time" (CR 603.2d, 611.2b): a resolved effect on the referenced permanents
54
+ # (locked in, CR 611.2c). It ends for good when its source untaps, leaves the battlefield or phases out
55
+ # (Engine#end_while_source_effects!); delayed and reflexive triggered abilities are never affected.
56
+ class AdditionalTriggersEffect < Effect
57
+ DURATIONS = %w[while_source_tapped].freeze
58
+
59
+ primitive :effect, :additional_triggers,
60
+ description: "For `duration` (while_source_tapped: for as long as the source remains tapped), each " \
61
+ "triggered ability of each referenced permanent triggers an additional time.",
62
+ rules: %w[603.2d 611.2b 611.2c],
63
+ params: { "to" => "selector", "duration" => DURATIONS.join(" | ") },
64
+ example: { "additional_triggers" => { "to" => { "target" => 0 }, "duration" => "while_source_tapped" } }
65
+
66
+ def self.keys = %w[to duration if]
67
+
68
+ def setup(params, errors, path)
69
+ @duration = Mechanics.fetch_param(params, :duration)
70
+ errors << "#{path}.duration: must be one of #{DURATIONS.join(", ")}" unless DURATIONS.include?(@duration)
71
+ end
72
+
73
+ # CR 611.2b: if the source isn't tapped (or is gone) as this resolves, the duration never starts.
74
+ def apply(engine, ctx)
75
+ source = engine.state.objects[ctx.source]
76
+ return unless source && engine.state.battlefield.include?(source.id) && source.tapped
77
+
78
+ engine.add_continuous_effect!("additional_triggers", ctx, objects(engine, ctx),
79
+ "duration" => @duration, "source" => ctx.source)
80
+ end
81
+
82
+ def to_s = "its triggered abilities trigger an additional time while the source remains tapped → #{to}"
83
+ end
84
+ end
85
+ end
86
+ end