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,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # Namespace of every registered primitive plus helpers to build them from card data.
5
+ module Mechanics
6
+ module_function
7
+
8
+ # Builds a primitive from a data entry. An entry is either a bare name ("tap")
9
+ # or a single-key Hash ({"mana" => {...params}}).
10
+ def build_entry(category, entry, errors, path, **context)
11
+ name, params = split_entry(entry)
12
+ unless name
13
+ errors << "#{path}: must be a name or a single-key object, got #{entry.inspect}"
14
+ return nil
15
+ end
16
+
17
+ klass = Enjine.registry.fetch(category, name)
18
+ unless klass
19
+ errors << "#{path}: unknown #{category} #{name.inspect} (supported: #{Enjine.registry.names(category).join(", ")})"
20
+ return nil
21
+ end
22
+ unless params.is_a?(Hash) || klass.respond_to?(:scalar_params?)
23
+ errors << "#{path}.#{name}: parameters must be an object"
24
+ return nil
25
+ end
26
+
27
+ klass.build(params, errors, "#{path}.#{name}", **context)
28
+ end
29
+
30
+ def split_entry(entry)
31
+ case entry
32
+ when String, Symbol then [entry.to_s, {}]
33
+ when Hash then entry.size == 1 ? [entry.keys.first.to_s, entry.values.first] : nil
34
+ end
35
+ end
36
+
37
+ # Reports parameter keys a primitive does not declare.
38
+ def check_keys(params, allowed, errors, path)
39
+ (params.keys.map(&:to_s) - allowed.map(&:to_s)).each do |key|
40
+ errors << "#{path}: unknown parameter #{key.inspect}"
41
+ end
42
+ end
43
+
44
+ def fetch_param(params, key)
45
+ params.key?(key.to_s) ? params[key.to_s] : params[key.to_sym]
46
+ end
47
+
48
+ def integer_param(params, key, errors, path, default: nil, min: nil)
49
+ value = fetch_param(params, key)
50
+ value = default if value.nil?
51
+ unless value.is_a?(Integer) && (min.nil? || value >= min)
52
+ errors << "#{path}.#{key}: must be an integer#{" >= #{min}" if min}"
53
+ return default || 0
54
+ end
55
+ value
56
+ end
57
+
58
+ # An optional true/false parameter; absent is false.
59
+ def flag_param(params, key, errors, path)
60
+ value = fetch_param(params, key)
61
+ errors << "#{path}.#{key}: must be true or false" unless [nil, true, false].include?(value)
62
+ value == true
63
+ end
64
+
65
+ def list_param(params, key, errors, path)
66
+ value = fetch_param(params, key)
67
+ return [] if value.nil?
68
+ return value if value.is_a?(Array)
69
+
70
+ errors << "#{path}.#{key}: must be a list"
71
+ []
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # A named player with a deck. The player is #ready? when the deck is valid.
5
+ # While seated in an unfinished game the deck cannot be edited (DeckLocked).
6
+ class Player
7
+ attr_reader :name, :deck
8
+
9
+ def initialize(name:, deck:)
10
+ raise ArgumentError, "player name must be a non-empty string" unless name.is_a?(String) && !name.empty?
11
+
12
+ @name = name.dup.freeze
13
+ @deck = Deck.from_h(deck)
14
+ @games = ObjectSpace::WeakMap.new
15
+ @deck.guard = lambda {
16
+ raise DeckLocked, "#{@name} is seated in an unfinished game; finish or concede it first" if playing?
17
+ }
18
+ end
19
+
20
+ def ready? = @deck.valid?
21
+
22
+ # True while any game this player is seated in has not ended.
23
+ def playing? = @games.keys.any? { |game| !game.over? }
24
+
25
+ # @api private
26
+ def seat(game)
27
+ @games[game] = true
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # Base class of every mechanic the engine understands.
5
+ #
6
+ # A primitive is a named building block that card data can reference:
7
+ # a card type, supertype, subtype, ability, cost or effect. Each subclass
8
+ # declares its metadata with `primitive`; the declaration both registers it
9
+ # in Enjine.registry and feeds the generated reference docs.
10
+ class Primitive
11
+ CATEGORIES = %i[type supertype subtype keyword ability trigger cost effect filter counter].freeze
12
+
13
+ class << self
14
+ attr_reader :category, :primitive_name, :description, :rules, :params, :example
15
+
16
+ # Declares this class as the primitive `name` of `category` and registers it.
17
+ def primitive(category, name, description:, rules: [], params: {}, example: nil)
18
+ raise ArgumentError, "unknown category #{category}" unless CATEGORIES.include?(category)
19
+
20
+ @category = category
21
+ @primitive_name = name.to_s
22
+ @description = description
23
+ @rules = rules.freeze
24
+ @params = params.freeze
25
+ @example = example
26
+ Enjine.registry.register(self)
27
+ end
28
+ end
29
+ end
30
+
31
+ # Holds every registered primitive, keyed by category and name.
32
+ class Registry
33
+ def initialize
34
+ @entries = Primitive::CATEGORIES.to_h { |c| [c, {}] }
35
+ end
36
+
37
+ def register(klass)
38
+ bucket = @entries.fetch(klass.category)
39
+ name = klass.primitive_name
40
+ raise ArgumentError, "#{klass.category} primitive #{name.inspect} already registered" if bucket.key?(name) && bucket[name] != klass
41
+
42
+ bucket[name] = klass
43
+ end
44
+
45
+ def fetch(category, name)
46
+ @entries.fetch(category)[name.to_s]
47
+ end
48
+
49
+ def names(category)
50
+ @entries.fetch(category).keys.sort
51
+ end
52
+
53
+ def each(category, &)
54
+ @entries.fetch(category).sort.map(&:last).each(&)
55
+ end
56
+ end
57
+
58
+ def self.registry
59
+ @registry ||= Registry.new
60
+ end
61
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # A state change about to happen (CR 614.1). Mutations are proposed first, passed
5
+ # through replacement effects, then executed, so "enters tapped", "if it would die,
6
+ # exile it instead" and damage prevention can modify them before they occur.
7
+ #
8
+ # kind: "zone_change" | "damage" | "life" | "draw" | "counters" | "untap" | "create_token"
9
+ # data: plain Hash describing the change; a replacement that stops the change entirely
10
+ # ("instead remove a stun counter") sets data["cancelled"] = true
11
+ # applied: ids of replacement effects already applied (each applies once, CR 614.5)
12
+ Proposal = Struct.new(:kind, :data, :applied)
13
+
14
+ # The replacement-effect pass (CR 614-616).
15
+ #
16
+ # Applies, in order: the entering permanent's own self-replacement effects and the rules' own
17
+ # entering modifications from card types (e.g. a planeswalker enters with loyalty counters,
18
+ # CR 306.5b, 616.1a), entering replacements of permanents already on the battlefield (extra
19
+ # counters on permanents entering under their controller's control, CR 614.1c, 614.12), and the
20
+ # effects of counters on the affected permanent (stun and shield counters, CR 122.1c-d).
21
+ #
22
+ # CR 616.1 lets the affected player (or the affected object's controller) choose the order when
23
+ # several effects apply. The engine applies them in the order that player would always choose:
24
+ # effects that let a permanent stay ("instead remove all +1/+1 counters", a shield counter) before
25
+ # effects that send it elsewhere ("exile it instead"), "+1 counter" bonuses before doubling, and
26
+ # "enters untapped" after every "enters tapped". No pair implemented so far has two orders that
27
+ # each benefit that player, so no decision is asked; a frame will when one does.
28
+ module Replacements
29
+ module_function
30
+
31
+ def apply(engine, proposal)
32
+ engine.cast_replacements!(proposal) if proposal.kind == "zone_change" # beam me up, cast riders (Permissions)
33
+ case proposal.kind
34
+ when "zone_change" then zone_change!(engine, proposal)
35
+ when "counters" then putting_counters!(engine, proposal)
36
+ when "draw" then drawing!(engine, proposal)
37
+ when "damage" then Mechanics::DamageEvents.replace!(engine, proposal) # abilities/damage_replacements.rb
38
+ when "create_token" then creating_token!(engine, proposal)
39
+ end
40
+ Mechanics::Counters.replace!(engine, proposal) unless proposal.data["cancelled"]
41
+ dying!(engine, proposal) if proposal.kind == "zone_change" && !proposal.data["cancelled"]
42
+ proposal
43
+ end
44
+
45
+ def zone_change!(engine, proposal)
46
+ return entering!(engine, proposal) if proposal.data["to"] == :battlefield
47
+
48
+ destroying!(engine, proposal)
49
+ end
50
+
51
+ # The first applicable ability of a battlefield permanent controlled by `player` that responds to
52
+ # `hook` and hasn't modified this event yet (CR 614.5). It is marked as applied.
53
+ def next_controlled(engine, proposal, hook, player)
54
+ found = battlefield_abilities(engine, hook).find do |source, _|
55
+ source.controller == player && !proposal.applied.include?("#{source.id}:#{hook}")
56
+ end
57
+ proposal.applied << "#{found[0].id}:#{hook}" if found
58
+ found
59
+ end
60
+
61
+ # "If you would draw a card, draw two cards instead" (CR 614.11). Only one applies to this event;
62
+ # the others apply to each of the draws it becomes (Engine#draw!).
63
+ def drawing!(engine, proposal)
64
+ _, ability = next_controlled(engine, proposal, :replace_draw, proposal.data["player"])
65
+ proposal.data["count"] = ability.amount if ability
66
+ end
67
+
68
+ # "If one or more tokens would be created under your control, that many [tokens] are created
69
+ # instead" (CR 614.1a, 111.1).
70
+ def creating_token!(engine, proposal)
71
+ _, ability = next_controlled(engine, proposal, :token_definition, proposal.data["player"])
72
+ proposal.data["token"] = ability.token_definition.id if ability
73
+ proposal.data["count"] *= 2 while next_controlled(engine, proposal, :doubles_tokens, proposal.data["player"]) # "twice that many"
74
+ end
75
+
76
+ # CR 701.8a, 704.5g-h: destroying a permanent, by an effect or for lethal damage / deathtouch.
77
+ DESTROY_REASONS = %w[destroy sba_lethal_damage sba_deathtouch].freeze
78
+
79
+ # "If [a permanent] would be destroyed, instead ..." (CR 614.8-style destruction replacements).
80
+ def destroying!(engine, proposal)
81
+ return unless DESTROY_REASONS.include?(proposal.data["reason"])
82
+
83
+ obj = engine.object(proposal.data["object"]) # destroyed: on the battlefield (CR 701.8a)
84
+ battlefield_abilities(engine, :replace_destroy).each do |source, ability|
85
+ next if proposal.applied.include?("#{source.id}:replace_destroy") || !ability.replaces?(engine, obj, source)
86
+
87
+ proposal.applied << "#{source.id}:replace_destroy"
88
+ proposal.data["cancelled"] = true
89
+ return ability.replace_destroy(engine, obj)
90
+ end
91
+ end
92
+
93
+ # "If it would die this turn, exile it instead" and "If a creature an opponent controls would die, exile it
94
+ # instead" (CR 614.1a, 700.4).
95
+ def dying!(engine, proposal)
96
+ data = proposal.data
97
+ return unless data["to"] == :graveyard && engine.state.battlefield.include?(data["object"])
98
+
99
+ obj = engine.object(data["object"])
100
+ this_turn = engine.state.effects.any? { it["kind"] == "exile_if_dies" && it["affected"].include?(obj.id) }
101
+ static = battlefield_abilities(engine, :exiles_dying?).find { |source, ability| ability.exiles_dying?(engine, obj, source) }
102
+ data["to"] = :exile if this_turn || static
103
+ # The one replacement that applies (CR 614.5) is the static's when there is one: its follow-up and link.
104
+ (data["follow_ups"] ||= []) << static[1].follow_up_entry(static[0], obj) if static
105
+ end
106
+
107
+ # Bonus counters from "until end of turn, ... that many plus one of each of those kinds" effects of
108
+ # the permanent's controller (CR 614.1a).
109
+ def counter_bonus(engine, controller)
110
+ engine.state.effects.sum { it["kind"] == "counter_bonus" && it["controller"] == controller ? it["amount"] : 0 }
111
+ end
112
+
113
+ # [[source permanent, ability]] for abilities of permanents on the battlefield that respond to `hook`.
114
+ def battlefield_abilities(engine, hook)
115
+ engine.state.battlefield.flat_map do |id|
116
+ source = engine.object(id)
117
+ engine.chars(source).abilities.filter_map { [source, it.ability] if it.ability.respond_to?(hook) }
118
+ end
119
+ end
120
+
121
+ # "Put twice that many counters instead" (CR 614.1a) on counters put on a permanent.
122
+ def putting_counters!(engine, proposal)
123
+ obj = engine.object(proposal.data["object"])
124
+ facts = Mechanics::Filters::Facts.new(chars: engine.chars(obj), counters: obj.counters,
125
+ token: engine.token?(obj))
126
+ proposal.data["amount"] += counter_bonus(engine, obj.controller) # "that many plus one" first (CR 616.1)
127
+ battlefield_abilities(engine, :doubles?).each do |source, ability|
128
+ next unless ability.doubles?(source, proposal.data["player"], obj.controller, proposal.data["counter"], facts)
129
+
130
+ proposal.data["amount"] *= 2
131
+ end
132
+ end
133
+
134
+ # CR 614.12-style status the permanent enters with.
135
+ def entering!(engine, proposal)
136
+ obj = engine.object(proposal.data["object"])
137
+ enters = proposal.data["enters"]
138
+ # Self-replacement effects of the entering permanent (CR 614.12: as it would exist on the battlefield,
139
+ # with the face it enters with up, CR 712.9).
140
+ face = engine.entering_face(obj, enters)
141
+ (face || engine.definition(obj)).ability_instances.each do |inst|
142
+ inst.ability.modify_entering(enters) if inst.ability.respond_to?(:modify_entering)
143
+ if inst.ability.respond_to?(:modify_entering_for) # a condition checked for its controller (CR 614.1c)
144
+ inst.ability.modify_entering_for(engine, proposal.data["controller"] || obj.owner, obj.id, enters)
145
+ end
146
+ end
147
+ (face ? face.types : engine.chars(obj).types).each do |type|
148
+ mod = Enjine.registry.fetch(:type, type)
149
+ mod.entering(face || engine.definition(obj), enters) if mod.respond_to?(:entering)
150
+ end
151
+ others_entering!(engine, obj, proposal)
152
+ end
153
+
154
+ # Entering replacements of permanents already on the battlefield (CR 614.1c, 614.12).
155
+ def others_entering!(engine, obj, proposal)
156
+ controller = proposal.data["controller"] || obj.owner
157
+ facts = Mechanics::Filters::Facts.new(chars: engine.chars(obj), counters: {}, token: engine.token?(obj))
158
+ enters = proposal.data["enters"]
159
+ battlefield_abilities(engine, :modify_other_entering).each do |source, ability|
160
+ ability.modify_other_entering(enters) if source.id != obj.id && ability.affects_entering?(source, controller, facts)
161
+ end
162
+ # Counters it enters with are put on it by its controller (CR 122.6, 122.6a).
163
+ bonus = counter_bonus(engine, controller)
164
+ enters["counters"].transform_values! { it + bonus } if bonus.positive?
165
+ battlefield_abilities(engine, :doubles?).each do |source, ability|
166
+ enters["counters"].each_key do |kind|
167
+ enters["counters"][kind] *= 2 if ability.doubles?(source, controller, controller, kind, facts)
168
+ end
169
+ end
170
+ engine.chars(obj).subtypes.each do |sub| # e.g. a Saga enters with a lore counter (CR 715.3a)
171
+ mod = Enjine.registry.fetch(:subtype, sub)
172
+ mod.entering(engine.definition(obj), proposal.data["enters"]) if mod.respond_to?(:entering)
173
+ end
174
+ entering_status!(engine, obj, controller, facts, enters)
175
+ end
176
+
177
+ # "Lands you control enter untapped" overrides every "enters tapped" (applied last, CR 616.1), and
178
+ # "would enter while you control an attacking creature: instead it enters tapped and attacking".
179
+ def entering_status!(engine, obj, controller, facts, enters)
180
+ battlefield_abilities(engine, :enters_attacking?).each do |source, ability|
181
+ next unless source.id != obj.id && ability.enters_attacking?(engine, source, controller, facts)
182
+
183
+ enters["tapped"] = true
184
+ enters["attacking"] = true
185
+ end
186
+ battlefield_abilities(engine, :enters_untapped?).each do |source, ability|
187
+ enters["tapped"] = false if ability.enters_untapped?(source, controller, facts)
188
+ end
189
+ end
190
+ end
191
+ end
data/lib/enjine/rng.rb ADDED
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # PCG32 (XSH-RR) random number generator.
5
+ #
6
+ # The whole generator state is one 64-bit Integer, so it serializes into a
7
+ # snapshot as a plain number and replays identically on every platform.
8
+ class Rng
9
+ MULTIPLIER = 6_364_136_223_846_793_005
10
+ INCREMENT = 1_442_695_040_888_963_407
11
+ MASK64 = (1 << 64) - 1
12
+ MASK32 = (1 << 32) - 1
13
+ RANGE32 = 1 << 32
14
+
15
+ attr_reader :state
16
+
17
+ def self.from_seed(seed)
18
+ rng = new(0)
19
+ rng.next_u32
20
+ rng.instance_variable_set(:@state, (rng.state + (Integer(seed) & MASK64)) & MASK64)
21
+ rng.next_u32
22
+ rng
23
+ end
24
+
25
+ def initialize(state)
26
+ @state = Integer(state) & MASK64
27
+ end
28
+
29
+ def next_u32
30
+ old = @state
31
+ @state = ((old * MULTIPLIER) + INCREMENT) & MASK64
32
+ xorshifted = (((old >> 18) ^ old) >> 27) & MASK32
33
+ rot = old >> 59
34
+ ((xorshifted >> rot) | (xorshifted << ((-rot) & 31))) & MASK32
35
+ end
36
+
37
+ # Unbiased integer in 0...n (Lemire-free rejection sampling).
38
+ def rand(bound)
39
+ raise ArgumentError, "bound must be in 1..2**32" unless bound.is_a?(Integer) && bound.positive? && bound <= RANGE32
40
+
41
+ threshold = (RANGE32 - bound) % bound
42
+ loop do
43
+ r = next_u32
44
+ return r % bound if r >= threshold
45
+ end
46
+ end
47
+
48
+ # In-place Fisher-Yates shuffle.
49
+ def shuffle!(array)
50
+ (array.size - 1).downto(1) do |i|
51
+ j = rand(i + 1)
52
+ array[i], array[j] = array[j], array[i]
53
+ end
54
+ array
55
+ end
56
+ end
57
+ end