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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/SUPPORTED.md +387 -0
- data/docs/ARCHITECTURE.md +141 -0
- data/docs/DESIGN.md +139 -0
- data/docs/MECHANICS_COVERAGE.md +670 -0
- data/docs/guides/adding-a-mechanic.md +60 -0
- data/docs/guides/api.md +614 -0
- data/docs/guides/card-data-format.md +1472 -0
- data/docs/guides/deck-builder.md +157 -0
- data/docs/plans/standard.md +371 -0
- data/docs/reference/abilities.md +1680 -0
- data/docs/reference/costs.md +339 -0
- data/docs/reference/counters.md +121 -0
- data/docs/reference/decisions.md +102 -0
- data/docs/reference/effects.md +2340 -0
- data/docs/reference/filters.md +172 -0
- data/docs/reference/keywords.md +647 -0
- data/docs/reference/subtypes.md +169 -0
- data/docs/reference/supertypes.md +31 -0
- data/docs/reference/triggers.md +797 -0
- data/docs/reference/types.md +216 -0
- data/exe/enjine +7 -0
- data/lib/enjine/actions/activate_mana_ability.rb +24 -0
- data/lib/enjine/actions/cast.rb +57 -0
- data/lib/enjine/actions/pass.rb +18 -0
- data/lib/enjine/actions/play_land.rb +52 -0
- data/lib/enjine/actions/special_action.rb +37 -0
- data/lib/enjine/actions.rb +20 -0
- data/lib/enjine/agents/busy.rb +27 -0
- data/lib/enjine/agents/random.rb +28 -0
- data/lib/enjine/card_definition.rb +433 -0
- data/lib/enjine/card_faces.rb +116 -0
- data/lib/enjine/catalog.rb +56 -0
- data/lib/enjine/characteristics.rb +39 -0
- data/lib/enjine/cli/text.rb +295 -0
- data/lib/enjine/cli.rb +312 -0
- data/lib/enjine/decisions/bottom.rb +29 -0
- data/lib/enjine/decisions/casting.rb +188 -0
- data/lib/enjine/decisions/combat.rb +75 -0
- data/lib/enjine/decisions/discard.rb +29 -0
- data/lib/enjine/decisions/effect_choice.rb +21 -0
- data/lib/enjine/decisions/enter_choice.rb +26 -0
- data/lib/enjine/decisions/legend_rule.rb +21 -0
- data/lib/enjine/decisions/mulligan.rb +26 -0
- data/lib/enjine/decisions/order_triggers.rb +21 -0
- data/lib/enjine/decisions/play_or_draw.rb +19 -0
- data/lib/enjine/decisions/priority.rb +22 -0
- data/lib/enjine/decisions/untap_choice.rb +35 -0
- data/lib/enjine/decisions.rb +22 -0
- data/lib/enjine/deck.rb +151 -0
- data/lib/enjine/deck_builder.rb +454 -0
- data/lib/enjine/docs.rb +90 -0
- data/lib/enjine/engine/ability_layers.rb +118 -0
- data/lib/enjine/engine/attachments.rb +86 -0
- data/lib/enjine/engine/casting/alt_casting.rb +254 -0
- data/lib/enjine/engine/casting/cast_choices.rb +292 -0
- data/lib/enjine/engine/casting/casting.rb +326 -0
- data/lib/enjine/engine/casting/cost_options.rb +99 -0
- data/lib/enjine/engine/casting/costs.rb +117 -0
- data/lib/enjine/engine/casting/payment.rb +353 -0
- data/lib/enjine/engine/casting/permissions.rb +412 -0
- data/lib/enjine/engine/casting/target_counts.rb +113 -0
- data/lib/enjine/engine/casting/waterbend.rb +90 -0
- data/lib/enjine/engine/characteristics.rb +379 -0
- data/lib/enjine/engine/class_levels.rb +25 -0
- data/lib/enjine/engine/combat.rb +315 -0
- data/lib/enjine/engine/copies.rb +208 -0
- data/lib/enjine/engine/damage.rb +196 -0
- data/lib/enjine/engine/designations.rb +46 -0
- data/lib/enjine/engine/emblems.rb +37 -0
- data/lib/enjine/engine/entering.rb +156 -0
- data/lib/enjine/engine/face_down.rb +30 -0
- data/lib/enjine/engine/faces.rb +222 -0
- data/lib/enjine/engine/follow_ups.rb +27 -0
- data/lib/enjine/engine/mana_sources.rb +230 -0
- data/lib/enjine/engine/phasing.rb +74 -0
- data/lib/enjine/engine/pregame.rb +95 -0
- data/lib/enjine/engine/priority.rb +47 -0
- data/lib/enjine/engine/resolution.rb +168 -0
- data/lib/enjine/engine/rules.rb +287 -0
- data/lib/enjine/engine/sagas.rb +41 -0
- data/lib/enjine/engine/speed.rb +104 -0
- data/lib/enjine/engine/spell_grants.rb +46 -0
- data/lib/enjine/engine/state_based_actions.rb +157 -0
- data/lib/enjine/engine/triggers.rb +364 -0
- data/lib/enjine/engine/turn_history.rb +240 -0
- data/lib/enjine/engine/turn_structure.rb +224 -0
- data/lib/enjine/engine/zones.rb +278 -0
- data/lib/enjine/engine.rb +55 -0
- data/lib/enjine/errors.rb +62 -0
- data/lib/enjine/events.rb +161 -0
- data/lib/enjine/frames.rb +84 -0
- data/lib/enjine/fuzz.rb +112 -0
- data/lib/enjine/game.rb +204 -0
- data/lib/enjine/history.rb +26 -0
- data/lib/enjine/mana.rb +142 -0
- data/lib/enjine/mechanics/abilities/additional_triggers.rb +86 -0
- data/lib/enjine/mechanics/abilities/chosen_values.rb +213 -0
- data/lib/enjine/mechanics/abilities/cost_changes.rb +154 -0
- data/lib/enjine/mechanics/abilities/damage_replacements.rb +569 -0
- data/lib/enjine/mechanics/abilities/double_counters.rb +71 -0
- data/lib/enjine/mechanics/abilities/entering_choices.rb +91 -0
- data/lib/enjine/mechanics/abilities/entering_copy.rb +69 -0
- data/lib/enjine/mechanics/abilities/enters_tapped.rb +159 -0
- data/lib/enjine/mechanics/abilities/enters_with_counters.rb +116 -0
- data/lib/enjine/mechanics/abilities/mana_ability.rb +209 -0
- data/lib/enjine/mechanics/abilities/play_permissions.rb +527 -0
- data/lib/enjine/mechanics/abilities/player_rules.rb +169 -0
- data/lib/enjine/mechanics/abilities/replacement_effects.rb +368 -0
- data/lib/enjine/mechanics/abilities/restrictions.rb +144 -0
- data/lib/enjine/mechanics/abilities/spell_keywords.rb +55 -0
- data/lib/enjine/mechanics/abilities/stack_abilities.rb +542 -0
- data/lib/enjine/mechanics/abilities/static.rb +365 -0
- data/lib/enjine/mechanics/abilities/tapping_power.rb +71 -0
- data/lib/enjine/mechanics/abilities/turn_rules.rb +49 -0
- data/lib/enjine/mechanics/card_spec.rb +177 -0
- data/lib/enjine/mechanics/copy_except.rb +73 -0
- data/lib/enjine/mechanics/costs/base.rb +115 -0
- data/lib/enjine/mechanics/costs/behold.rb +76 -0
- data/lib/enjine/mechanics/costs/discard.rb +65 -0
- data/lib/enjine/mechanics/costs/exert.rb +29 -0
- data/lib/enjine/mechanics/costs/exile_cards.rb +39 -0
- data/lib/enjine/mechanics/costs/from_graveyard.rb +45 -0
- data/lib/enjine/mechanics/costs/get_counters.rb +39 -0
- data/lib/enjine/mechanics/costs/mana.rb +37 -0
- data/lib/enjine/mechanics/costs/one_of.rb +49 -0
- data/lib/enjine/mechanics/costs/pay_life.rb +50 -0
- data/lib/enjine/mechanics/costs/remove_counters.rb +51 -0
- data/lib/enjine/mechanics/costs/sacrifice.rb +59 -0
- data/lib/enjine/mechanics/costs/tap.rb +56 -0
- data/lib/enjine/mechanics/costs/waterbend.rb +29 -0
- data/lib/enjine/mechanics/counters/kinds.rb +190 -0
- data/lib/enjine/mechanics/effects/add_subtypes.rb +30 -0
- data/lib/enjine/mechanics/effects/airbend.rb +41 -0
- data/lib/enjine/mechanics/effects/amass.rb +124 -0
- data/lib/enjine/mechanics/effects/assimilate.rb +72 -0
- data/lib/enjine/mechanics/effects/base.rb +148 -0
- data/lib/enjine/mechanics/effects/become.rb +182 -0
- data/lib/enjine/mechanics/effects/behold.rb +48 -0
- data/lib/enjine/mechanics/effects/blight.rb +94 -0
- data/lib/enjine/mechanics/effects/choose_cards.rb +168 -0
- data/lib/enjine/mechanics/effects/copy.rb +370 -0
- data/lib/enjine/mechanics/effects/counter_spell.rb +21 -0
- data/lib/enjine/mechanics/effects/counters.rb +161 -0
- data/lib/enjine/mechanics/effects/damage.rb +72 -0
- data/lib/enjine/mechanics/effects/delayed_triggers.rb +112 -0
- data/lib/enjine/mechanics/effects/destroy.rb +22 -0
- data/lib/enjine/mechanics/effects/discard.rb +86 -0
- data/lib/enjine/mechanics/effects/draw.rb +16 -0
- data/lib/enjine/mechanics/effects/earthbend.rb +70 -0
- data/lib/enjine/mechanics/effects/emblem.rb +52 -0
- data/lib/enjine/mechanics/effects/endure.rb +63 -0
- data/lib/enjine/mechanics/effects/exile.rb +49 -0
- data/lib/enjine/mechanics/effects/extra_turn.rb +122 -0
- data/lib/enjine/mechanics/effects/faces.rb +115 -0
- data/lib/enjine/mechanics/effects/fight.rb +76 -0
- data/lib/enjine/mechanics/effects/flip_coins.rb +97 -0
- data/lib/enjine/mechanics/effects/gain_control.rb +94 -0
- data/lib/enjine/mechanics/effects/game_end.rb +42 -0
- data/lib/enjine/mechanics/effects/library.rb +327 -0
- data/lib/enjine/mechanics/effects/life.rb +83 -0
- data/lib/enjine/mechanics/effects/look_at_hand.rb +17 -0
- data/lib/enjine/mechanics/effects/loot.rb +118 -0
- data/lib/enjine/mechanics/effects/mana.rb +96 -0
- data/lib/enjine/mechanics/effects/may.rb +82 -0
- data/lib/enjine/mechanics/effects/meld.rb +77 -0
- data/lib/enjine/mechanics/effects/mill.rb +22 -0
- data/lib/enjine/mechanics/effects/pay.rb +231 -0
- data/lib/enjine/mechanics/effects/phasing.rb +51 -0
- data/lib/enjine/mechanics/effects/proliferate.rb +72 -0
- data/lib/enjine/mechanics/effects/pump.rb +60 -0
- data/lib/enjine/mechanics/effects/put_in_library.rb +98 -0
- data/lib/enjine/mechanics/effects/return_choice.rb +29 -0
- data/lib/enjine/mechanics/effects/return_to_battlefield.rb +80 -0
- data/lib/enjine/mechanics/effects/return_to_hand.rb +20 -0
- data/lib/enjine/mechanics/effects/roll_dice.rb +45 -0
- data/lib/enjine/mechanics/effects/sacrifice.rb +118 -0
- data/lib/enjine/mechanics/effects/scry_and_surveil.rb +74 -0
- data/lib/enjine/mechanics/effects/search.rb +156 -0
- data/lib/enjine/mechanics/effects/tap_untap.rb +23 -0
- data/lib/enjine/mechanics/effects/tokens.rb +76 -0
- data/lib/enjine/mechanics/filters/any_target.rb +15 -0
- data/lib/enjine/mechanics/filters/base.rb +386 -0
- data/lib/enjine/mechanics/filters/graveyard.rb +23 -0
- data/lib/enjine/mechanics/filters/one_of.rb +55 -0
- data/lib/enjine/mechanics/filters/permanents.rb +52 -0
- data/lib/enjine/mechanics/filters/players.rb +20 -0
- data/lib/enjine/mechanics/filters/stack.rb +97 -0
- data/lib/enjine/mechanics/follow_up.rb +46 -0
- data/lib/enjine/mechanics/keywords/alternative_costs.rb +361 -0
- data/lib/enjine/mechanics/keywords/beam_me_up.rb +78 -0
- data/lib/enjine/mechanics/keywords/changeling.rb +58 -0
- data/lib/enjine/mechanics/keywords/convoke.rb +77 -0
- data/lib/enjine/mechanics/keywords/crew.rb +155 -0
- data/lib/enjine/mechanics/keywords/cycling.rb +58 -0
- data/lib/enjine/mechanics/keywords/decayed.rb +25 -0
- data/lib/enjine/mechanics/keywords/devour.rb +61 -0
- data/lib/enjine/mechanics/keywords/evergreen.rb +54 -0
- data/lib/enjine/mechanics/keywords/extort.rb +26 -0
- data/lib/enjine/mechanics/keywords/flags.rb +46 -0
- data/lib/enjine/mechanics/keywords/foretell.rb +45 -0
- data/lib/enjine/mechanics/keywords/gift.rb +50 -0
- data/lib/enjine/mechanics/keywords/hideaway.rb +28 -0
- data/lib/enjine/mechanics/keywords/increment.rb +31 -0
- data/lib/enjine/mechanics/keywords/job_select.rb +29 -0
- data/lib/enjine/mechanics/keywords/kicker.rb +92 -0
- data/lib/enjine/mechanics/keywords/mobilize_and_firebending.rb +84 -0
- data/lib/enjine/mechanics/keywords/paradigm.rb +91 -0
- data/lib/enjine/mechanics/keywords/power_up.rb +48 -0
- data/lib/enjine/mechanics/keywords/protection.rb +111 -0
- data/lib/enjine/mechanics/keywords/prowess.rb +30 -0
- data/lib/enjine/mechanics/keywords/riot.rb +35 -0
- data/lib/enjine/mechanics/keywords/saddle.rb +115 -0
- data/lib/enjine/mechanics/keywords/speed.rb +89 -0
- data/lib/enjine/mechanics/keywords/station.rb +163 -0
- data/lib/enjine/mechanics/keywords/storied.rb +41 -0
- data/lib/enjine/mechanics/keywords/storm_and_cascade.rb +86 -0
- data/lib/enjine/mechanics/keywords/sunburst.rb +29 -0
- data/lib/enjine/mechanics/keywords/suspend.rb +191 -0
- data/lib/enjine/mechanics/keywords/teamwork.rb +45 -0
- data/lib/enjine/mechanics/keywords/ward.rb +67 -0
- data/lib/enjine/mechanics/linked.rb +67 -0
- data/lib/enjine/mechanics/mana_spent.rb +99 -0
- data/lib/enjine/mechanics/selector.rb +207 -0
- data/lib/enjine/mechanics/subtypes/auras_and_equipment.rb +176 -0
- data/lib/enjine/mechanics/subtypes/basic_land_types.rb +36 -0
- data/lib/enjine/mechanics/subtypes/class_levels.rb +102 -0
- data/lib/enjine/mechanics/subtypes/saga.rb +89 -0
- data/lib/enjine/mechanics/supertypes/basic.rb +17 -0
- data/lib/enjine/mechanics/supertypes/legendary.rb +18 -0
- data/lib/enjine/mechanics/triggers/ability_triggers.rb +51 -0
- data/lib/enjine/mechanics/triggers/any_of.rb +43 -0
- data/lib/enjine/mechanics/triggers/base.rb +228 -0
- data/lib/enjine/mechanics/triggers/casting.rb +219 -0
- data/lib/enjine/mechanics/triggers/combat.rb +123 -0
- data/lib/enjine/mechanics/triggers/damage.rb +130 -0
- data/lib/enjine/mechanics/triggers/dilemma.rb +37 -0
- data/lib/enjine/mechanics/triggers/draw_and_discard.rb +85 -0
- data/lib/enjine/mechanics/triggers/keyword_actions.rb +55 -0
- data/lib/enjine/mechanics/triggers/life.rb +35 -0
- data/lib/enjine/mechanics/triggers/steps.rb +39 -0
- data/lib/enjine/mechanics/triggers/tap_and_counters.rb +111 -0
- data/lib/enjine/mechanics/triggers/zone_changes.rb +249 -0
- data/lib/enjine/mechanics/types/kindred.rb +30 -0
- data/lib/enjine/mechanics/types/land.rb +24 -0
- data/lib/enjine/mechanics/types/permanent_types.rb +67 -0
- data/lib/enjine/mechanics/types/spell_types.rb +38 -0
- data/lib/enjine/mechanics/values.rb +675 -0
- data/lib/enjine/mechanics.rb +74 -0
- data/lib/enjine/player.rb +30 -0
- data/lib/enjine/primitive.rb +61 -0
- data/lib/enjine/replacements.rb +191 -0
- data/lib/enjine/rng.rb +57 -0
- data/lib/enjine/rules_monitor.rb +404 -0
- data/lib/enjine/setup.rb +45 -0
- data/lib/enjine/snapshot/player_checks.rb +86 -0
- data/lib/enjine/snapshot/turn_checks.rb +91 -0
- data/lib/enjine/snapshot.rb +307 -0
- data/lib/enjine/state.rb +173 -0
- data/lib/enjine/steps.rb +117 -0
- data/lib/enjine/tokens.rb +25 -0
- data/lib/enjine/verify.rb +233 -0
- data/lib/enjine/version.rb +7 -0
- data/lib/enjine/view.rb +162 -0
- data/lib/enjine.rb +46 -0
- 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
|
data/lib/enjine/docs.rb
ADDED
|
@@ -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
|