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,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "enjine"
4
+
5
+ module Enjine
6
+ # Builds random, legal, *plausibly shaped* constructed decks from a card pool it keeps in
7
+ # memory (require "enjine/deck_builder"). It knows nothing of sets: the pool is whatever cards
8
+ # you give it. No synergy: just the proportions every 60-card deck shares, drawn as ranges
9
+ # with occasional outliers.
10
+ #
11
+ # builder = Enjine::DeckBuilder.new(cards) # card data blocks and/or CardDefinitions
12
+ # builder.build(colors: %w[W U], seed: 7) # explicit colors
13
+ # builder.build(colors: 2, seed: 8) # two random colors
14
+ # builder.build(seed: 9, size: 80) # :any: a random number of colors (profile :random_colors)
15
+ # builder.build(colors: "G", seed: 3, including: ["Giant Growth"]) # these cards, then random ones
16
+ #
17
+ # A pool entry may also be {"card" => card, "count" => n}: a collection you own, so a deck
18
+ # never holds more than n copies (and never more than CR 100.2a allows). Basic lands are always
19
+ # available: the pool's own, else built-in ones.
20
+ #
21
+ # #build returns a deck document ({"name", "description", "cards", "main"}) that
22
+ # Enjine::Player.new(name:, deck:) accepts. Same pool, arguments and seed, same deck: randomness
23
+ # comes only from Enjine::Rng, and the pool is sorted by card id, so its order doesn't matter.
24
+ class DeckBuilder
25
+ # The pool and colors don't have enough playable cards. #details says what was missing.
26
+ class NotEnoughCards < Error
27
+ attr_reader :details
28
+
29
+ def initialize(message, details)
30
+ @details = details.freeze
31
+ super(message)
32
+ end
33
+ end
34
+
35
+ # Default deck shape. Every count is for 60 cards and scales linearly with `size:`.
36
+ # Counts are drawn from `range` with a triangular weight peaking at `center`; with
37
+ # probability `outlier_chance` (percent) a count is instead drawn uniformly from
38
+ # the wider `outliers` range, so the odd 20-land aggro or 27-land control deck appears.
39
+ DEFAULT_PROFILE = {
40
+ # 60-card constructed decks overwhelmingly play 22-26 lands (24 is the classic
41
+ # "40%" baseline); very low curves dip to ~20, heavy control reaches ~27.
42
+ lands: { range: 22..26, center: 24, outliers: 20..27 },
43
+ # Creature counts vary the most between archetypes: ~14 for midrange/control
44
+ # shells, up to ~24 for creature aggro; spell-heavy or creature-only extremes are rarer.
45
+ creatures: { range: 14..24, center: 18, outliers: 10..28 },
46
+ # Non-creature spells (instants, sorceries, artifacts, enchantments,
47
+ # planeswalkers) take the remaining slots.
48
+ outlier_chance: 10,
49
+ # Relative weight of each mana value when choosing the next distinct card
50
+ # (0 counts as 1, 6 means 6+): most cards cost 1-3, fewer 4, few 5+.
51
+ curve: { 1 => 16, 2 => 28, 3 => 24, 4 => 16, 5 => 9, 6 => 7 },
52
+ # Copies of each distinct non-basic card: constructed decks are mostly
53
+ # 4-ofs, with some 2-3-ofs and a few singletons.
54
+ copies: { 4 => 50, 3 => 20, 2 => 20, 1 => 10 },
55
+ # Non-basic land copies (when the pool has lands that fit the colors):
56
+ # a mono-color deck plays a few utility lands, a multicolor deck several duals.
57
+ nonbasic_lands: { mono: 0..4, multi: 2..8 },
58
+ # Number of colors when `colors: :any`: two-color decks are the most common.
59
+ random_colors: { 1 => 30, 2 => 50, 3 => 20 },
60
+ # In multicolor decks, the % chance that the next card is drawn from the least
61
+ # represented chosen color (by colored mana symbols), so no chosen color ends up a
62
+ # token splash. 0 disables balancing; 100 forces strict balance.
63
+ color_balance: 75
64
+ }.freeze
65
+
66
+ BASIC_LAND_NAMES = { W: "Plains", U: "Island", B: "Swamp", R: "Mountain", G: "Forest" }.freeze
67
+ # Used when the pool has no basic land of a color (CR 305.6: the land type grants the mana ability).
68
+ BASIC_LANDS = BASIC_LAND_NAMES.transform_values do |name|
69
+ { "name" => name, "types" => ["land"], "supertypes" => ["basic"], "subtypes" => [name.downcase] }.freeze
70
+ end.freeze
71
+ BASE_SIZE = 60
72
+
73
+ # The colors of a card: the colored and hybrid symbols in its mana cost (CR 105.2, 202.2, 202.2d),
74
+ # in WUBRG order. A hybrid card needs both of its colors in the deck.
75
+ def self.colors_of(definition) = definition.mana_cost&.colors || []
76
+
77
+ # The colors a deck must be able to produce to use a card: its colors plus the colored
78
+ # symbols in its activation costs, so an off-color activated ability never sneaks in.
79
+ def self.required_colors(definition)
80
+ costs = definition.abilities.flat_map { it.respond_to?(:costs) ? it.costs : [] }.select { it.respond_to?(:mana_cost) }
81
+ symbols = costs.flat_map { it.mana_cost.colored.keys }
82
+ Mana::COLORS.select { colors_of(definition).include?(it) || symbols.include?(it) }
83
+ end
84
+
85
+ # The colors of mana a permanent's mana abilities produce, in WUBRG order ({C} excluded).
86
+ def self.produced_colors(definition)
87
+ produced = definition.mana_abilities.flat_map { it.produce || it.colors.map(&:to_sym) }
88
+ Mana::COLORS.select { produced.include?(it) }
89
+ end
90
+
91
+ # One of the five basic lands every deck draws its basics from (Wastes is not: no deck color makes it).
92
+ def self.basic?(definition) = BASIC_LAND_NAMES.value?(definition.name) && definition.supertype?(:basic)
93
+
94
+ # Colorless cards fit every deck (CR 105.2c); others fit when all their colors are deck colors.
95
+ def self.fits?(definition, colors)
96
+ required_colors(definition).all? { colors.include?(it) }
97
+ end
98
+
99
+ # Normalizes "WU", %w[w u] or [:W, :U] to [:W, :U] in WUBRG order.
100
+ def self.parse_colors(value)
101
+ letters = value.is_a?(Array) ? value.map(&:to_s) : value.to_s.delete(", ").chars
102
+ symbols = letters.map { it.strip.upcase.to_sym }
103
+ bad = symbols - Mana::COLORS
104
+ raise ArgumentError, "unknown color #{bad.join(", ")} (use W U B R G)" unless bad.empty?
105
+ raise ArgumentError, "choose at least one color" if symbols.empty?
106
+
107
+ Mana::COLORS.select { symbols.include?(it) }
108
+ end
109
+
110
+ # The pool's cards, one CardDefinition per card, sorted by id.
111
+ attr_reader :cards
112
+
113
+ # pool: card data blocks, CardDefinitions, or {"card" => either, "count" => copies owned}.
114
+ # Entries for the same card add up; a card listed once without a count has no cap.
115
+ def initialize(pool)
116
+ definitions = {}
117
+ owned = Hash.new(0)
118
+ uncapped = []
119
+ Array(pool).each do |entry|
120
+ card, count = pool_entry(entry)
121
+ defn = CardDefinition.from_h(card)
122
+ held = definitions[defn.id] ||= defn
123
+ raise CatalogConflict, "two different definitions for #{defn.name.inspect} (#{defn.id})" unless held == defn
124
+
125
+ count ? owned[defn.id] += count : uncapped << defn.id
126
+ end
127
+ @cards = definitions.values.sort_by(&:id).freeze
128
+ @by_id = definitions.freeze
129
+ @owned = owned.except(*uncapped).freeze
130
+ end
131
+
132
+ # seed: seed for Enjine::Rng
133
+ # colors: %w[W U], "WU", an Integer 1-5 (that many random colors), or :any (random count, see random_colors)
134
+ # size: deck size, at least 60; every profile count scales with it
135
+ # profile: overrides for DEFAULT_PROFILE, merged key by key
136
+ # including: pool cards (names or ids) the deck must hold, as many copies as allowed up to 4; they must fit
137
+ # the colors (a land: produce only deck colors). They define the deck, so a chosen color then
138
+ # needs no card of its own: it may only bring basics.
139
+ def build(seed:, colors: :any, size: BASE_SIZE, profile: {}, including: [])
140
+ Draft.new(self, seed: seed, colors: colors, size: size, profile: profile, including: including).deck
141
+ end
142
+
143
+ # Copies of a card the pool holds: nil when uncapped, 0 when it isn't in the pool.
144
+ def count(card)
145
+ id = CardDefinition.slug(card)
146
+ @by_id.key?(id) ? @owned[id] : 0
147
+ end
148
+
149
+ def definition(id) = @by_id.fetch(id)
150
+
151
+ private
152
+
153
+ def pool_entry(entry)
154
+ return [entry, nil] unless entry.is_a?(Hash) && (entry.key?("card") || entry.key?(:card))
155
+
156
+ entry = entry.transform_keys(&:to_s)
157
+ count = entry["count"]
158
+ unless count.is_a?(Integer) && count.positive?
159
+ raise ArgumentError,
160
+ "pool entry count must be a positive Integer, got #{count.inspect}"
161
+ end
162
+
163
+ [entry["card"], count]
164
+ end
165
+
166
+ # One #build call: the seed-dependent choices for one deck.
167
+ class Draft
168
+ def initialize(builder, seed:, colors:, size:, profile:, including:)
169
+ raise ArgumentError, "size must be an Integer of at least #{Deck::MIN_SIZE}" unless size.is_a?(Integer) && size >= Deck::MIN_SIZE
170
+
171
+ @builder = builder
172
+ @pool = builder.cards
173
+ @rng = Rng.from_seed(seed)
174
+ @seed = seed
175
+ @size = size
176
+ @profile = merge_profile(profile)
177
+ @colors = resolve_colors(colors)
178
+ @creatures = {}
179
+ @others = {}
180
+ @lands = {}
181
+ include_cards(including)
182
+ end
183
+
184
+ def deck
185
+ check_colors! if @creatures.empty? && @others.empty? && @lands.empty?
186
+ lands = [scaled(sample(@profile[:lands])), @lands.values.sum].max
187
+ creatures = scaled(sample(@profile[:creatures])).clamp(0, @size - lands)
188
+ lands += fill_spells(lands, creatures)
189
+ document(lands)
190
+ end
191
+
192
+ private
193
+
194
+ # --- inputs -------------------------------------------------------------------------------------
195
+
196
+ def merge_profile(overrides)
197
+ unknown = overrides.keys.map(&:to_sym) - DEFAULT_PROFILE.keys
198
+ raise ArgumentError, "unknown profile keys: #{unknown.join(", ")}" unless unknown.empty?
199
+
200
+ DEFAULT_PROFILE.to_h do |key, default|
201
+ value = overrides.fetch(key, overrides.fetch(key.to_s, default))
202
+ [key, default.is_a?(Hash) ? default.merge(value) : value]
203
+ end
204
+ end
205
+
206
+ def resolve_colors(value)
207
+ case value
208
+ when :any, "any", nil then random_colors(weighted(@profile[:random_colors]))
209
+ when Integer then random_colors(value)
210
+ when /\A\d\z/ then random_colors(value.to_i)
211
+ else DeckBuilder.parse_colors(value)
212
+ end
213
+ end
214
+
215
+ # A uniformly random color combination among those the pool can actually fill.
216
+ def random_colors(count)
217
+ raise ArgumentError, "a deck has 1 to 5 colors, not #{count}" unless (1..5).cover?(count)
218
+
219
+ combos = Mana::COLORS.combination(count).to_a
220
+ viable = combos.select { viable?(it) }
221
+ return viable[@rng.rand(viable.size)] unless viable.empty?
222
+
223
+ combos.max_by { capacity(spells_for(it)) } # nothing fits: the closest one, which then explains what's missing
224
+ end
225
+
226
+ def viable?(colors)
227
+ spells = spells_for(colors)
228
+ colors.all? { |c| spells.any? { DeckBuilder.colors_of(it).include?(c) } } &&
229
+ capacity(spells) >= @size - scaled(@profile[:lands][:outliers].max)
230
+ end
231
+
232
+ def spells_for(colors) = @pool.select { it.castable? && DeckBuilder.fits?(it, colors) }
233
+ def capacity(definitions) = definitions.sum { limit(it) }
234
+
235
+ # Most copies of a card the deck may hold: 4 (CR 100.2a) or any number if exempt, and never
236
+ # more than the pool holds.
237
+ def limit(definition) = [definition.deck_limit || @size, @builder.count(definition.id) || @size].min
238
+
239
+ # Puts each required card in the deck with as many copies as it may hold, up to 4 (basics come anyway).
240
+ def include_cards(cards)
241
+ cards.each do |card|
242
+ id = CardDefinition.slug(card)
243
+ raise ArgumentError, "#{card.inspect} is not in the pool" if @builder.count(id)&.zero?
244
+
245
+ defn = @builder.definition(id)
246
+ fits = defn.land? ? DeckBuilder.produced_colors(defn).all? { @colors.include?(it) } : DeckBuilder.fits?(defn, @colors)
247
+ raise ArgumentError, "#{defn.name} does not fit a #{@colors.join} deck" unless fits
248
+ next if DeckBuilder.basic?(defn) # every deck has its basics
249
+
250
+ group = if defn.land? then @lands
251
+ else defn.creature? ? @creatures : @others
252
+ end
253
+ group[id] = [limit(defn), Deck::MAX_COPIES].min
254
+ end
255
+ end
256
+
257
+ # --- spells -------------------------------------------------------------------------------------
258
+
259
+ # Fills the non-land slots. Scarcity, in order: more copies of chosen cards (up to 4),
260
+ # then the creature/other split moves, then up to the outlier land maximum. Returns extra lands.
261
+ def fill_spells(lands, creature_target)
262
+ creature_pool, other_pool = spells_for(@colors).partition(&:creature?)
263
+ others_target = @size - lands - creature_target
264
+ creatures = pick(creature_pool, creature_target, @creatures)
265
+ others = pick(other_pool, others_target + (creature_target - creatures), @others)
266
+ missing = @size - lands - creatures - others
267
+ missing -= pick(creature_pool, creatures + missing, @creatures) - creatures if missing.positive?
268
+ extra = missing.clamp(0, [scaled(@profile[:lands][:outliers].max) - lands, 0].max)
269
+ not_enough!(creature_pool, other_pool, lands + extra, missing - extra) if missing > extra
270
+ extra
271
+ end
272
+
273
+ # Adds distinct cards (by curve weight) to `chosen` until it holds `target` cards, then
274
+ # tops up copies. Returns the number of cards in `chosen`.
275
+ def pick(pool, target, chosen)
276
+ candidates = pool.reject { chosen.key?(it.id) }
277
+ filled = chosen.values.sum
278
+ missing_colors.each do |color|
279
+ colored = candidates.select { DeckBuilder.colors_of(it).include?(color) }
280
+ filled = take(colored.empty? ? nil : by_curve(colored), candidates, chosen, filled, target)
281
+ end
282
+ filled = take(by_curve(balanced(candidates)), candidates, chosen, filled, target) while filled < target && !candidates.empty?
283
+ top_up(chosen, filled, target)
284
+ end
285
+
286
+ def take(definition, candidates, chosen, filled, target)
287
+ return filled if definition.nil? || filled >= target
288
+
289
+ candidates.delete(definition)
290
+ copies = [weighted(@profile[:copies]), target - filled, limit(definition)].min
291
+ chosen[definition.id] = [copies, 1].max
292
+ filled + chosen[definition.id]
293
+ end
294
+
295
+ def top_up(chosen, filled, target)
296
+ while filled < target
297
+ open = chosen.keys.select { chosen[it] < limit(@builder.definition(it)) }
298
+ break if open.empty?
299
+
300
+ open.each do |id|
301
+ break if filled >= target
302
+
303
+ chosen[id] += 1
304
+ filled += 1
305
+ end
306
+ end
307
+ filled
308
+ end
309
+
310
+ # Candidates restricted to the least represented chosen color (see profile :color_balance).
311
+ def balanced(candidates)
312
+ return candidates if @colors.size < 2 || @rng.rand(100) >= @profile[:color_balance]
313
+
314
+ symbols = Hash.new(0)
315
+ (@creatures.to_a + @others.to_a).each do |id, n|
316
+ @builder.definition(id).mana_cost&.colored&.each { |color, k| symbols[color] += k * n }
317
+ end
318
+ weakest = @colors.min_by { [symbols[it], it] }
319
+ preferred = candidates.select { DeckBuilder.colors_of(it).include?(weakest) }
320
+ preferred.empty? ? candidates : preferred
321
+ end
322
+
323
+ # Multicolor decks start with one card of each color not yet represented.
324
+ def missing_colors
325
+ have = (@creatures.keys + @others.keys).flat_map { DeckBuilder.colors_of(@builder.definition(it)) }
326
+ @colors.size > 1 ? @colors - have : []
327
+ end
328
+
329
+ def by_curve(candidates)
330
+ buckets = candidates.group_by { it.mana_cost.mana_value.clamp(1, 6) }
331
+ weights = @profile[:curve].select { |mv, w| buckets.key?(mv) && w.positive? }
332
+ bucket = weights.empty? ? buckets.keys.min : weighted(weights)
333
+ cards = buckets.fetch(bucket)
334
+ cards[@rng.rand(cards.size)]
335
+ end
336
+
337
+ def not_enough!(creature_pool, other_pool, lands, missing)
338
+ details = base_details.merge(lands: lands, creatures_available: creature_pool.size, others_available: other_pool.size,
339
+ max_spell_copies: capacity(creature_pool + other_pool), missing: missing)
340
+ raise NotEnoughCards.new(
341
+ "cannot build a #{@size}-card #{@colors.join} deck from #{@pool.size} cards: only #{creature_pool.size} creature and " \
342
+ "#{other_pool.size} other non-land cards are playable in those colors (at most #{details[:max_spell_copies]} copies), " \
343
+ "#{missing} short of the #{@size - lands} non-land cards needed even with #{lands} lands. " \
344
+ "Add cards or colors, or lower the profile's ranges.", details
345
+ )
346
+ end
347
+
348
+ # Every deck color needs at least one playable card (a "WU" deck with no blue cards isn't one).
349
+ def check_colors!
350
+ lacking = colors_without_cards
351
+ return if lacking.empty?
352
+
353
+ raise NotEnoughCards.new(
354
+ "cannot build a #{@colors.join} deck from #{@pool.size} cards: none of them is a playable #{lacking.join(", ")} card",
355
+ base_details
356
+ )
357
+ end
358
+
359
+ def colors_without_cards
360
+ spells = spells_for(@colors)
361
+ @colors.reject { |c| spells.any? { DeckBuilder.colors_of(it).include?(c) } }
362
+ end
363
+
364
+ def base_details = { pool: @pool.size, colors: @colors.join, size: @size, colors_without_cards: colors_without_cards.join }
365
+
366
+ # --- lands --------------------------------------------------------------------------------------
367
+
368
+ def pick_nonbasic_lands(total)
369
+ pool = @pool.select { nonbasic_land?(it) }
370
+ range = @profile[:nonbasic_lands][@colors.size == 1 ? :mono : :multi]
371
+ want = [scaled(uniform(range)), total / 2].min.clamp(@lands.values.sum..)
372
+ chosen = @lands.dup
373
+ pool.reject! { chosen.key?(it.id) }
374
+ take(by_land_weight(pool), pool, chosen, chosen.values.sum, want) while chosen.values.sum < want && !pool.empty?
375
+ chosen
376
+ end
377
+
378
+ def nonbasic_land?(definition)
379
+ definition.land? && !definition.supertype?(:basic) && !definition.mana_abilities.empty? &&
380
+ DeckBuilder.produced_colors(definition).all? { @colors.include?(it) }
381
+ end
382
+
383
+ # Weight 4 for lands that produce two or more deck colors (duals), 2 for one color, 1 for colorless-only.
384
+ def by_land_weight(pool)
385
+ weights = pool.map { [1, 2, 4][DeckBuilder.produced_colors(it).size.clamp(0, 2)] }
386
+ roll = @rng.rand(weights.sum)
387
+ pool.each_with_index { |defn, i| (roll -= weights[i]).negative? && (return defn) }
388
+ end
389
+
390
+ # Basic lands split by the deck's colored mana symbols: every color that has symbols
391
+ # gets at least one, the rest is proportional (largest remainder, ties in WUBRG order).
392
+ def split_basics(count)
393
+ pips = @colors.to_h { |c| [c, spell_entries.sum { |defn, n| (defn.mana_cost&.colored || {}).fetch(c, 0) * n }] }
394
+ pips = @colors.to_h { [it, 1] } if pips.values.sum.zero?
395
+ split = pips.to_h { |c, n| [c, n.positive? && count >= pips.count { it[1].positive? } ? 1 : 0] }
396
+ rest = count - split.values.sum
397
+ total = pips.values.sum
398
+ shares = pips.to_h { |c, n| [c, Rational(rest * n, total)] }
399
+ shares.each { |c, share| split[c] += share.floor }
400
+ leftover = count - split.values.sum
401
+ shares.sort_by { |c, share| [-(share - share.floor), @colors.index(c)] }.first(leftover).each { split[it[0]] += 1 }
402
+ split.reject { |_, n| n.zero? }
403
+ end
404
+
405
+ # --- output -------------------------------------------------------------------------------------
406
+
407
+ def document(lands)
408
+ nonbasic = pick_nonbasic_lands(lands)
409
+ basics = split_basics(lands - nonbasic.values.sum)
410
+ main = basics.map { |color, n| [basic_land(color), n] } + sorted(nonbasic) + spell_entries
411
+ { "name" => "Random #{@colors.join} deck",
412
+ "description" => "random deck: colors=#{@colors.join} seed=#{@seed} — #{lands} lands, " \
413
+ "#{@creatures.values.sum} creatures, #{@others.values.sum} other spells, #{main.size} distinct cards",
414
+ "cards" => main.map { it[0].to_h },
415
+ "main" => main.map { |defn, n| { "card" => defn.name, "count" => n } } }
416
+ end
417
+
418
+ def sorted(chosen)
419
+ chosen.map { |id, n| [@builder.definition(id), n] }.sort_by { |defn, _| [defn.mana_cost&.mana_value || 0, defn.name] }
420
+ end
421
+
422
+ def spell_entries = sorted(@creatures) + sorted(@others)
423
+
424
+ def basic_land(color)
425
+ name = BASIC_LAND_NAMES.fetch(color)
426
+ @pool.find { it.id == CardDefinition.slug(name) && it.supertype?(:basic) } || CardDefinition.from_h(BASIC_LANDS[color])
427
+ end
428
+
429
+ # --- randomness ---------------------------------------------------------------------------------
430
+
431
+ def sample(spec)
432
+ return uniform(spec[:outliers]) if @rng.rand(100) < @profile[:outlier_chance]
433
+
434
+ range = spec[:range]
435
+ center = spec.fetch(:center) { (range.min + range.max) / 2 }
436
+ spread = [center - range.min, range.max - center].max + 1
437
+ weighted(range.to_h { [it, spread - (it - center).abs] })
438
+ end
439
+
440
+ def uniform(range) = range.min + @rng.rand(range.max - range.min + 1)
441
+ def scaled(count) = Rational(count * @size, BASE_SIZE).round
442
+
443
+ # A key of `weights` ({key => Integer weight}), chosen proportionally; keys are tried in order.
444
+ def weighted(weights)
445
+ weights = weights.select { |_, w| w.positive? }
446
+ raise ArgumentError, "profile weights must include a positive weight" if weights.empty?
447
+
448
+ roll = @rng.rand(weights.values.sum)
449
+ weights.each { |key, w| (roll -= w).negative? && (return key) }
450
+ end
451
+ end
452
+ private_constant :Draft
453
+ end
454
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Enjine
7
+ # Generates the reference docs and support matrix from Enjine.registry, so the
8
+ # documentation always matches the code. `rake docs` writes them; a spec fails
9
+ # when the committed files drift.
10
+ module Docs
11
+ CATEGORY_TITLES = {
12
+ type: "Card types", supertype: "Supertypes", subtype: "Subtypes with rules", keyword: "Keyword abilities",
13
+ ability: "Abilities", trigger: "Trigger conditions", cost: "Costs", effect: "Effects", filter: "Target filters and selectors",
14
+ counter: "Counters with rules"
15
+ }.freeze
16
+ ROOT = File.expand_path("../..", __dir__)
17
+
18
+ module_function
19
+
20
+ # {relative path => content}
21
+ def generate
22
+ files = { "SUPPORTED.md" => supported }
23
+ Primitive::CATEGORIES.each { |c| files["docs/reference/#{file_for(c)}.md"] = reference(c) }
24
+ files["docs/reference/decisions.md"] = decisions
25
+ files
26
+ end
27
+
28
+ def write(root = ROOT)
29
+ generate.each do |path, content|
30
+ full = File.join(root, path)
31
+ FileUtils.mkdir_p(File.dirname(full))
32
+ File.write(full, content)
33
+ end
34
+ end
35
+
36
+ def supported
37
+ lines = ["# Supported mechanics", "", generated_note, "",
38
+ "Rules version: Comprehensive Rules effective #{RULES_VERSION}.", ""]
39
+ Primitive::CATEGORIES.each do |category|
40
+ lines << "## #{CATEGORY_TITLES.fetch(category)}" << ""
41
+ entries = Enjine.registry.each(category).to_a
42
+ if entries.empty?
43
+ lines << "_None yet._" << ""
44
+ next
45
+ end
46
+ lines << "| Name | Rules | Description |" << "| --- | --- | --- |"
47
+ entries.each do |klass|
48
+ lines << "| [`#{klass.primitive_name}`](docs/reference/#{file_for(category)}.md##{klass.primitive_name}) | " \
49
+ "#{klass.rules.join(", ")} | #{klass.description} |"
50
+ end
51
+ lines << ""
52
+ end
53
+ lines << "## Decisions" << "" << "See [docs/reference/decisions.md](docs/reference/decisions.md)." << ""
54
+ lines.join("\n")
55
+ end
56
+
57
+ def reference(category)
58
+ lines = ["# #{CATEGORY_TITLES.fetch(category)}", "", generated_note, ""]
59
+ entries = Enjine.registry.each(category).to_a
60
+ lines << "_None yet._" << "" if entries.empty?
61
+ entries.each do |klass|
62
+ lines << "## #{klass.primitive_name}" << "" << klass.description << ""
63
+ lines << "Rules: #{klass.rules.map { "CR #{it}" }.join(", ")}" << "" unless klass.rules.empty?
64
+ unless klass.params.empty?
65
+ lines << "| Parameter | Description |" << "| --- | --- |"
66
+ klass.params.each { |k, v| lines << "| `#{k}` | #{v} |" }
67
+ lines << ""
68
+ end
69
+ lines << "```json" << JSON.pretty_generate(klass.example) << "```" << "" if klass.example
70
+ end
71
+ lines.join("\n")
72
+ end
73
+
74
+ def decisions
75
+ lines = ["# Decisions", "", generated_note, "",
76
+ "`game.pending_decision` returns the decision below; submit one of its `options`.", ""]
77
+ Decisions::KINDS.sort.each do |kind, klass|
78
+ lines << "## #{kind}" << "" << (klass.respond_to?(:doc) ? klass.doc : "") << ""
79
+ end
80
+ lines << "## Priority actions" << ""
81
+ Actions::BY_TYPE.sort.each { |type, klass| lines << "- `#{type}` — #{klass.doc}" }
82
+ lines << ""
83
+ lines.join("\n")
84
+ end
85
+
86
+ def file_for(category) = category == :ability ? "abilities" : "#{category}s"
87
+
88
+ def generated_note = "<!-- Generated by `rake docs` from the primitive registry. Do not edit. -->"
89
+ end
90
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Layers 5 and 6 of the layer pass (Engine::Characteristics#apply_layers): colors, and abilities gained
6
+ # and lost in timestamp order (CR 613.1e-f, 613.7).
7
+ module AbilityLayers
8
+ # Layer 5 (CR 613.1e): effects that set colors, in timestamp order. Layer 6 (CR 613.1f): keyword counters
9
+ # (CR 122.1b), keywords and abilities from static abilities and resolved effects, and "loses all
10
+ # abilities", applied in timestamp order (CR 613.7): a removal takes away what the object had then, and
11
+ # what is granted later stays. A static ability's timestamp is its source's (CR 613.7a), an effect's is
12
+ # its own (613.7b) and a keyword counter's is when it was put on (613.7c, see #counter_timestamp). A
13
+ # granted ability is referenced "granted:<card>:<ref>" (the granting card's data holds it, see
14
+ # #granted_origin); one a static ability grants ends with "@<granting permanent>", so it can refer to that
15
+ # permanent ("Return Trusty Boomerang to its owner's hand", CR 607.1a, Selector "granter").
16
+ def ability_layers(obj, base, statics, effects)
17
+ base = color_layer(obj, base, statics, effects)
18
+ changes = Mechanics::Counters.keywords(obj.counters).map { |kw| [counter_timestamp(obj, kw.to_s), 0, [:keywords, [kw]]] }
19
+ # A creature cast with suspend has haste while its caster controls it (CR 702.62a); since it was cast.
20
+ changes << [0, 0, [:keywords, [:haste]]] if obj.memory["haste_while_controlled_by"] == obj.controller
21
+ statics.each do |source, ability|
22
+ next unless ability.ability_changes? && ability.affects?(obj, source, facts(obj, base))
23
+
24
+ changes << [timestamp_of(source), 1, [:lose]] if ability.loses_abilities?
25
+ changes << [timestamp_of(source), 1, [:keywords, ability.keywords, granted_instances(source, ability)]]
26
+ end
27
+ effects.each do |effect|
28
+ next unless effect["affected"].include?(obj.id)
29
+
30
+ change = case effect["kind"]
31
+ when "lose_abilities" then [:lose]
32
+ when "grant" then [:keywords, effect["keywords"].map(&:to_sym)]
33
+ when "abilities" then [:keywords, [], effect_granted_instances(effect)] # e.g. Effects::Become
34
+ end
35
+ changes << [effect["timestamp"], 1, change] if change
36
+ end
37
+ return base if changes.empty?
38
+
39
+ apply_ability_changes(base, changes.each_with_index.sort_by { |(ts, rank, _), i| [ts, rank, i] }.map { it[0][2] })
40
+ end
41
+
42
+ # Folds layer 6 changes in order. Keywords stand for their abilities (prowess is a triggered ability),
43
+ # so a keyword gained, or kept after a removal, brings its instances (CR 613.1f).
44
+ def apply_ability_changes(base, changes)
45
+ keywords = base.keywords
46
+ abilities = base.abilities
47
+ lost = false
48
+ changes.each do |kind, added, granted|
49
+ if kind == :lose
50
+ lost = true
51
+ keywords = []
52
+ abilities = []
53
+ else
54
+ keywords |= added
55
+ abilities += granted unless granted.nil? || granted.empty?
56
+ end
57
+ end
58
+ return base if keywords == base.keywords && abilities.equal?(base.abilities)
59
+
60
+ abilities += (lost ? keywords : keywords - base.keywords).flat_map { CardDefinition.keyword_instances(it) }
61
+ base.with(keywords: keywords.freeze, abilities: abilities.freeze)
62
+ end
63
+
64
+ # Layer 5: statics (by their source's timestamp) and resolved effects that set colors, the latest last.
65
+ def color_layer(obj, base, statics, effects)
66
+ sets = statics.filter_map do |source, ability|
67
+ [timestamp_of(source), ability.colors] if ability.colors && ability.affects?(obj, source, facts(obj, base))
68
+ end
69
+ effects.each { sets << [it["timestamp"], it["colors"]] if it["kind"] == "colors" && it["affected"].include?(obj.id) }
70
+ return base if sets.empty?
71
+
72
+ base.with(colors: Mana::COLORS & sets.max_by(&:first)[1].map(&:to_sym))
73
+ end
74
+
75
+ # CR 613.7a, 613.7d: a permanent's timestamp is when it entered the battlefield; its id carries the
76
+ # counter it was created with, which is the timestamp sequence (#next_timestamp!). An Aura or Equipment gets
77
+ # a new one each time it becomes attached (CR 613.7e) and a permanent each time it transforms (CR 613.7g).
78
+ def timestamp_of(obj) = [obj.id[/\d+\z/].to_i, obj.memory["attached_at"].to_i, obj.memory["transformed_at"].to_i].max
79
+
80
+ # CR 613.7c: a keyword counter's timestamp is when it was last put on (Engine#add_counters! records it);
81
+ # one it had as it entered (or from a test board) counts as earliest.
82
+ def counter_timestamp(obj, kind) = obj.memory.dig("counter_timestamps", kind) || 0
83
+
84
+ # Whether the permanent loses all abilities this layer pass (an effect or a static ability, CR 613.1f).
85
+ def losing_abilities?(obj, statics, effects)
86
+ effects.any? { it["kind"] == "lose_abilities" && it["affected"].include?(obj.id) } ||
87
+ statics.any? { |source, ability| ability.loses_abilities? && ability.affects?(obj, source, facts(obj, @layering[obj.id])) }
88
+ end
89
+
90
+ def granted_instances(source, ability)
91
+ defn = definition(source)
92
+ ref = own_ability_instances(source).find { it.ability.equal?(ability) }&.ref or return []
93
+ defn.nested_instances(ref).map { AbilityInstance.new(ref: "granted:#{defn.id}:#{it.ref}@#{source.id}", ability: it.ability) }
94
+ end
95
+
96
+ # Abilities a resolved effect grants (layer 6, CR 613.1f): the effect's card holds them under its ref.
97
+ def effect_granted_instances(effect)
98
+ catalog.fetch(effect["card"]).effect_granted(effect["ref"]).map do |inst|
99
+ AbilityInstance.new(ref: "granted:#{effect["card"]}:#{inst.ref}", ability: inst.ability)
100
+ end
101
+ end
102
+
103
+ # For a granted ability's reference: [granting card id, its ref in that card], else nil.
104
+ def granted_origin(ref) = AbilityLayers.granted_parts(ref)&.first(2)
105
+
106
+ # [granting card id, its ref in that card, granting permanent id or nil] of a granted ability's reference.
107
+ def self.granted_parts(ref)
108
+ return nil unless ref.start_with?("granted:")
109
+
110
+ card, inner = ref.delete_prefix("granted:").split(":", 2)
111
+ inner, granter = inner.split("@", 2)
112
+ [card, inner, granter]
113
+ end
114
+ end
115
+
116
+ include AbilityLayers
117
+ end
118
+ end