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,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Cards with more than one face (CR 712 double-faced, 716 adventurer, 721 omen, 723 preparation cards;
|
|
6
|
+
# see CardDefinition::Faces). The face that is up is the object's `card`: a face's id ("alfa/1") while
|
|
7
|
+
# that face is up, the card's own id while the front face is. So every characteristic, ability and
|
|
8
|
+
# copiable value (CR 707.8) comes from the face that's up with no special case.
|
|
9
|
+
#
|
|
10
|
+
# memory["transformed_at"] when the permanent last transformed (a timestamp), CR 701.27g
|
|
11
|
+
# memory["prepared"] the id of the prepare spell copy in exile while it's prepared (CR 723.3c)
|
|
12
|
+
# memory["prepared_for"] on that copy: the prepared permanent
|
|
13
|
+
# memory["melded"] on a melded permanent (its object is one card of the pair, back face up): the
|
|
14
|
+
# other card's id (CR 713.1a)
|
|
15
|
+
module Faces
|
|
16
|
+
ALTERNATIVE_SPELLS = %w[modal adventure omen].freeze
|
|
17
|
+
|
|
18
|
+
# ---- reading -----------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
# CR 701.27h: a double-faced permanent with its back face up.
|
|
21
|
+
def transformed?(obj)
|
|
22
|
+
defn = catalog.fetch(own_card(obj))
|
|
23
|
+
obj.zone == :battlefield && defn.double_faced? && defn.face_index.positive?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def prepared?(obj) = obj.zone == :battlefield && obj.memory.key?("prepared")
|
|
27
|
+
|
|
28
|
+
# The card an object is after moving to `to` (CR 400.7): its front face (CR 712.6a, 716.4, 721.4),
|
|
29
|
+
# except that a spell becoming a permanent keeps the face it had on the stack (CR 712.10) and a copy
|
|
30
|
+
# keeps the face it copies (CR 707.8, 716.3c, 723.3c).
|
|
31
|
+
def card_after_move(old, to)
|
|
32
|
+
own = own_card(old)
|
|
33
|
+
return own if old.memory["copy"] || (old.zone == :stack && to == :battlefield)
|
|
34
|
+
|
|
35
|
+
catalog.fetch(own).card.id
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# ---- entering with a face up (CR 712.9, 712.11a-b) --------------------------------------------
|
|
39
|
+
|
|
40
|
+
# Whether the card can enter the battlefield with face `index` up: it's a double-faced card and that
|
|
41
|
+
# face is a permanent (CR 712.11a-b); otherwise it stays where it is.
|
|
42
|
+
def can_enter_face?(obj, index)
|
|
43
|
+
defn = catalog.fetch(own_card(obj)).card
|
|
44
|
+
faced = defn.double_faced? || (defn.layout == "meld" && obj.memory.key?("melded")) # CR 713.1b: only melded
|
|
45
|
+
faced && index < defn.faces.size && defn.face(index).permanent?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def enter_face!(fresh, index)
|
|
49
|
+
fresh.card = catalog.fetch(fresh.card).card.face(index).id if can_enter_face?(fresh, index)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The definition of the face an entering object will have up (for its own entering replacements,
|
|
53
|
+
# CR 614.12), or nil when it enters as it is.
|
|
54
|
+
def entering_face(obj, enters)
|
|
55
|
+
index = enters && enters["face"]
|
|
56
|
+
index && can_enter_face?(obj, index) ? catalog.fetch(own_card(obj)).card.face(index) : nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ---- transforming (CR 701.27) ---------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
# Turns a double-faced permanent over (CR 701.27a). Nothing happens to a permanent that isn't a
|
|
62
|
+
# double-faced card or token (CR 701.27d; a copy of a double-faced card is, CR 707.8a) or whose other
|
|
63
|
+
# face is an instant or sorcery (CR 701.27e). It stays the same object (CR 701.27c, 712.15).
|
|
64
|
+
def transform!(obj)
|
|
65
|
+
own = catalog.fetch(own_card(obj))
|
|
66
|
+
return false unless obj.zone == :battlefield && own.double_faced?
|
|
67
|
+
|
|
68
|
+
into = own.other_face
|
|
69
|
+
return false if into.type?(:instant) || into.type?(:sorcery)
|
|
70
|
+
|
|
71
|
+
if obj.memory["own_card"] then obj.memory["own_card"] = into.id # still a copy of something else
|
|
72
|
+
else obj.card = into.id
|
|
73
|
+
end
|
|
74
|
+
obj.memory["transformed_at"] = next_timestamp!
|
|
75
|
+
emit Events::Transformed.new(object: obj.id, card: into.id)
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# CR 701.27g: an activated or triggered ability of the permanent transforms it only if it hasn't
|
|
80
|
+
# transformed since the ability was put on the stack (the ability object's id is numbered then).
|
|
81
|
+
def transform_still_allowed?(obj, ctx)
|
|
82
|
+
ability = state.objects[ctx.object]
|
|
83
|
+
return true unless ability&.ability? && ability.source == obj.id
|
|
84
|
+
|
|
85
|
+
at = obj.memory["transformed_at"] or return true
|
|
86
|
+
at < ability.id.split("#").last.to_i
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ---- meld (CR 701.42, 713) -------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
# CR 701.42b: two cards (not tokens or copies) of one meld pair: meld cards with the same back face name.
|
|
92
|
+
def meld_pair?(one, two)
|
|
93
|
+
a, b = [one, two].map { catalog.fetch(own_card(it)).card }
|
|
94
|
+
[one, two].none? { token?(it) || it.memory["copy"] } && a.id != b.id && [a, b].all? { it.layout == "meld" } &&
|
|
95
|
+
a.face(1).name == b.face(1).name
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# CR 701.42a, 713.6: the exiled cards `first` and `second` of a meld pair enter the battlefield as one
|
|
99
|
+
# permanent with the back face up, under `controller`'s control; `first`'s object stands for both. Cards
|
|
100
|
+
# that can't be melded stay where they are (CR 701.42c).
|
|
101
|
+
def meld!(first, second, controller)
|
|
102
|
+
return unless [first, second].all? { it.zone == :exile } && meld_pair?(first, second)
|
|
103
|
+
|
|
104
|
+
state.exile.delete(second.id)
|
|
105
|
+
state.objects.delete(second.id)
|
|
106
|
+
move!(first.id, :battlefield, controller: controller, reason: "meld",
|
|
107
|
+
enters: { "face" => 1, "memory" => { "melded" => second.card } })
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# CR 713.8, 713.8c: a melded permanent that leaves the battlefield puts its two cards into the new zone;
|
|
111
|
+
# the other card is a new object there too, and both are the result of what moved it.
|
|
112
|
+
def unmeld!(old, to, data)
|
|
113
|
+
other = create_object(old.memory["melded"], old.owner, to)
|
|
114
|
+
arrive!(other, old, data.merge("enters" => { "tapped" => false, "counters" => {} })) if to == :battlefield
|
|
115
|
+
state.zone_list(to, old.owner).unshift(other.id)
|
|
116
|
+
record_result!(other.id)
|
|
117
|
+
emit Events::ZoneChanged.new(card: other.card, old_id: nil, new_id: other.id, owner: name(old.owner),
|
|
118
|
+
from: "battlefield", to: to.to_s, reason: data["reason"])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# ---- casting another face (CR 712.8b, 716.3, 721.3) ----------------------------------------------
|
|
122
|
+
|
|
123
|
+
# The faces other than the front a card can be cast as: a modal card's nonland faces, an Adventure, an
|
|
124
|
+
# Omen. A preparation card's spell is cast only as a copy (CR 723.3).
|
|
125
|
+
def alternative_faces(obj)
|
|
126
|
+
defn = catalog.fetch(obj.card)
|
|
127
|
+
return [] if obj.memory["copy"] || defn.face_index.positive? || !ALTERNATIVE_SPELLS.include?(defn.layout)
|
|
128
|
+
|
|
129
|
+
(1...defn.faces.size).reject { defn.face(it).land? }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Evaluates the block with face `index` up (CR 712.8c, 716.3a: only that face is evaluated). Nothing in
|
|
133
|
+
# the block changes state.
|
|
134
|
+
def with_face(obj, index)
|
|
135
|
+
card = obj.card
|
|
136
|
+
obj.card = catalog.fetch(obj.card).face(index).id
|
|
137
|
+
yield
|
|
138
|
+
ensure
|
|
139
|
+
obj.card = card
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Priority options for casting the cards in `index`'s hand as another face.
|
|
143
|
+
def face_cast_options(index, id)
|
|
144
|
+
obj = object(id)
|
|
145
|
+
alternative_faces(obj).filter_map do |face|
|
|
146
|
+
{ type: "cast", object: id, face: face } if with_face(obj, face) { castable?(index, id) }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# CR 712.9: a modal double-faced card is played as a land with a land face up.
|
|
151
|
+
def face_land_options(id)
|
|
152
|
+
defn = catalog.fetch(object(id).card)
|
|
153
|
+
return [] unless defn.layout == "modal" && defn.face_index.zero?
|
|
154
|
+
|
|
155
|
+
(1...defn.faces.size).filter_map { { type: "play_land", object: id, face: it } if defn.face(it).land? }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The spell goes on the stack with the chosen face up (CR 712.8b, 716.3b, 721.3b).
|
|
159
|
+
def turn_spell_face!(obj, face)
|
|
160
|
+
obj.card = catalog.fetch(obj.card).face(face).id if face
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# CR 716.3d, 721.3d: a resolving Adventure is exiled (its controller may play the card while it stays
|
|
164
|
+
# exiled, not as an Adventure) and a resolving Omen is shuffled into its owner's library, instead of
|
|
165
|
+
# going to the graveyard. A copy isn't a card (it ceases to exist). True when handled.
|
|
166
|
+
def put_away_spell_face!(obj)
|
|
167
|
+
defn = catalog.fetch(obj.card)
|
|
168
|
+
return false if obj.memory["copy"] || defn.face_index.zero?
|
|
169
|
+
|
|
170
|
+
case defn.layout
|
|
171
|
+
when "adventure"
|
|
172
|
+
fresh = move!(obj.id, :exile, reason: "adventure")
|
|
173
|
+
grant_play_permission!(obj.controller, [fresh.id], duration: "while_exiled", play: "play") if fresh&.zone == :exile
|
|
174
|
+
when "omen"
|
|
175
|
+
move!(obj.id, :library, reason: "omen")
|
|
176
|
+
shuffle_library!(obj.owner)
|
|
177
|
+
else return false
|
|
178
|
+
end
|
|
179
|
+
true
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# ---- prepared (CR 723.3) --------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
# CR 723.3a, 723.3c: a permanent with a prepare spell (in its copiable values, CR 723.2b) becomes
|
|
185
|
+
# prepared: its controller creates a copy of the prepare spell in exile and may cast it while the
|
|
186
|
+
# permanent stays prepared. A permanent already prepared can't become prepared again.
|
|
187
|
+
def prepare!(obj)
|
|
188
|
+
defn = catalog.fetch(obj.card)
|
|
189
|
+
return false unless obj.zone == :battlefield && !obj.memory.key?("prepared") && defn.layout == "prepare"
|
|
190
|
+
return false if defn.face_index.positive?
|
|
191
|
+
|
|
192
|
+
spell = create_card_copy!([defn.face(1).id, {}], obj.controller, :exile)
|
|
193
|
+
spell.memory["prepared_for"] = obj.id
|
|
194
|
+
obj.memory["prepared"] = spell.id
|
|
195
|
+
grant_play_permission!(obj.controller, [spell.id], duration: "while_exiled", play: "cast")
|
|
196
|
+
emit Events::PreparedChanged.new(object: obj.id, prepared: true)
|
|
197
|
+
true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# CR 723.3b: it loses the designation; its copy in exile ceases to exist (Engine::Copies).
|
|
201
|
+
def unprepare!(obj)
|
|
202
|
+
return false unless obj.memory.delete("prepared")
|
|
203
|
+
|
|
204
|
+
emit Events::PreparedChanged.new(object: obj.id, prepared: false)
|
|
205
|
+
true
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# CR 723.3c: the copy stays in exile while its permanent is on the battlefield and prepared with it.
|
|
209
|
+
def prepared_copy?(obj)
|
|
210
|
+
perm = obj.zone == :exile && state.objects[obj.memory["prepared_for"].to_s]
|
|
211
|
+
perm ? perm.zone == :battlefield && perm.memory["prepared"] == obj.id : false
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# CR 723.3c: the permanent loses the designation as its copy becomes cast (`origin`: the copy's id).
|
|
215
|
+
def cast_prepared_copy!(origin)
|
|
216
|
+
perm = state.battlefield.map { object(it) }.find { it.memory["prepared"] == origin } or return
|
|
217
|
+
unprepare!(perm)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
include Faces
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# The rest of a replacement or prevention effect, done right after the event it modified (CR 615.5,
|
|
6
|
+
# 614.1a): "... and put that many +1/+1 counters on him", "... and you gain 2 life", "When you do, ..."
|
|
7
|
+
# (Mechanics::FollowUp). A replacement adds an entry to the event's data["follow_ups"].
|
|
8
|
+
module FollowUps
|
|
9
|
+
# `facts` overrides the entries' facts (the card exiled instead, known once the move is done). An entry
|
|
10
|
+
# with "link" links that card to its source ("exiled with [it]", CR 607.2a).
|
|
11
|
+
def run_follow_ups!(data, facts = {})
|
|
12
|
+
Array(data.delete("follow_ups")).each do |entry|
|
|
13
|
+
seen = entry["facts"].merge(facts).compact
|
|
14
|
+
link_exiled!(entry["source"], seen["object"]) if entry["link"] && seen["object"]
|
|
15
|
+
next unless entry["ref"]
|
|
16
|
+
|
|
17
|
+
effects = catalog.fetch(entry["card"]).ability(entry["ref"]).effects
|
|
18
|
+
memory = { "triggering" => seen, "source_card" => entry["card"] }
|
|
19
|
+
ctx = Mechanics::EffectContext.new(entry["controller"], entry["source"], entry["source"], [], [], [], memory)
|
|
20
|
+
effects.each { it.run(self, ctx, {}) } # effects that need no choice (CR 615.5)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
include FollowUps
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Mana the player could make (CR 106, 605): affordability of costs, mana abilities and their options,
|
|
6
|
+
# restricted mana (CR 106.6), mana costs of mana abilities paid from the pool (CR 605.3a), and adding
|
|
7
|
+
# mana to and emptying mana pools (CR 106.4).
|
|
8
|
+
module ManaSources
|
|
9
|
+
# Whether the cost could be paid with the mana pool plus untapped mana sources (CR 601.2g-h).
|
|
10
|
+
# Each colored symbol needs mana of that color, a hybrid symbol mana of either of its colors
|
|
11
|
+
# (CR 107.4e); generic is paid by anything left. `purpose` ({"spell" => id} or {"ability" => source})
|
|
12
|
+
# lets restricted mana count where it may be spent (CR 106.6).
|
|
13
|
+
def affordable?(index, cost, excluded_source, purpose = nil)
|
|
14
|
+
return true if cost.nil? || cost.mana_value.zero?
|
|
15
|
+
|
|
16
|
+
covers?(cost, mana_units(index, excluded_source, purpose))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Whether `units` can pay `cost`, a monocolored hybrid {2/B} as {B} or as {2} (CR 107.4e, 601.2b).
|
|
20
|
+
def covers?(cost, units)
|
|
21
|
+
cost.equivalents.any? { units.size >= it.mana_value && match_colored(it.requirements, units) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Every unit of mana the player could make: pool mana (one fixed type each), restricted pool mana
|
|
25
|
+
# that may be spent on `purpose`, and one unit per untapped source, with the set of types it could
|
|
26
|
+
# produce (a Treasure: any color).
|
|
27
|
+
def mana_units(index, excluded_source, purpose = nil)
|
|
28
|
+
pool_units(index, player(index).mana_pool, purpose) + source_units(index, [excluded_source], purpose) +
|
|
29
|
+
convoke_units(index, purpose) + # CR 702.51a (Engine::AltCasting)
|
|
30
|
+
waterbend_units(index, purpose, [excluded_source]) # CR 701.67a (Engine::Waterbending)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def pool_units(index, pool, purpose, restricted = player(index).restricted_mana)
|
|
34
|
+
units = pool.flat_map { |type, n| Array.new([n, 0].max) { [type] } } +
|
|
35
|
+
restricted.select { spendable?(it, purpose) }.map { [it["mana"].to_sym] }
|
|
36
|
+
as_any_type(units, purpose)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# CR 609.4b: mana spent "as though it were mana of any type" can pay any symbol.
|
|
40
|
+
def as_any_type(units, purpose) = purpose&.[]("any_type") ? units.map { Mana::TYPES } : units
|
|
41
|
+
|
|
42
|
+
# Whether a restricted pool entry may be spent on `purpose` (CR 106.6).
|
|
43
|
+
def spendable?(entry, purpose)
|
|
44
|
+
entry["spend_only"].nil? || Mechanics::SpendOnly.from_data(entry["spend_only"]).allows?(self, purpose)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# One unit per untapped source not in `excluded`; a source that makes several mana ({C}{C})
|
|
48
|
+
# gives that many units. A source of restricted mana counts only where that mana may be spent; one
|
|
49
|
+
# whose ability costs mana isn't counted (it converts pool mana, CR 605.3a).
|
|
50
|
+
def source_units(index, excluded, purpose = nil)
|
|
51
|
+
by_source = Hash.new { |h, k| h[k] = [] }
|
|
52
|
+
all_mana_sources(index).each do |id, ref|
|
|
53
|
+
next if excluded.include?(id)
|
|
54
|
+
|
|
55
|
+
ability = chars(id).ability(ref)
|
|
56
|
+
next if ability.mana_cost || (ability.spend_only && !ability.spend_only.allows?(self, purpose))
|
|
57
|
+
|
|
58
|
+
by_source[id].concat(ability.outcomes(self, object(id)))
|
|
59
|
+
end
|
|
60
|
+
units = by_source.values.flat_map { |options| Array.new(options.map(&:size).max) { options.flatten.uniq } }
|
|
61
|
+
as_any_type(units, purpose)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Bipartite matching (augmenting paths): can every colored or hybrid symbol (the list of types
|
|
65
|
+
# that can pay it) get its own unit?
|
|
66
|
+
def match_colored(symbols, units)
|
|
67
|
+
owner = Array.new(units.size)
|
|
68
|
+
symbols.each_index.all? { |s| augment(s, symbols, units, owner, Array.new(units.size, false)) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def augment(symbol, symbols, units, owner, seen)
|
|
72
|
+
units.each_index.any? do |u|
|
|
73
|
+
next false if seen[u] || !units[u].intersect?(symbols[symbol])
|
|
74
|
+
|
|
75
|
+
seen[u] = true
|
|
76
|
+
next false unless owner[u].nil? || augment(owner[u], symbols, units, owner, seen)
|
|
77
|
+
|
|
78
|
+
owner[u] = symbol
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Every activatable mana ability [id, ability_index] the player controls. Memoized only
|
|
84
|
+
# while legal options are being computed (state can't change then); recomputed otherwise.
|
|
85
|
+
def all_mana_sources(index)
|
|
86
|
+
return scan_mana_sources(index) unless @options_memo
|
|
87
|
+
|
|
88
|
+
@options_memo[index] ||= scan_mana_sources(index)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def scan_mana_sources(index)
|
|
92
|
+
out = []
|
|
93
|
+
state.battlefield.each do |id|
|
|
94
|
+
obj = object(id)
|
|
95
|
+
next unless obj.controller == index && !mana_abilities_forbidden?(obj) # Engine::Rules
|
|
96
|
+
|
|
97
|
+
chars(obj).mana_abilities.each { |inst| out << [id, inst.ref] if inst.ability.activatable?(self, obj) }
|
|
98
|
+
end
|
|
99
|
+
out
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Activation options for mana abilities: one per source, or one per mana it could add when the
|
|
103
|
+
# mana is chosen as it's activated (`mana`: e.g. "{G}{G}", CR 605.3b).
|
|
104
|
+
def mana_activation_options(sources)
|
|
105
|
+
sources.flat_map do |id, ref|
|
|
106
|
+
ability = chars(id).ability(ref)
|
|
107
|
+
next [{ type: "activate_mana", object: id, ability: ref }] unless ability.choice?
|
|
108
|
+
|
|
109
|
+
ability.outcomes(self, object(id)).map { { type: "activate_mana", object: id, ability: ref, mana: Mana.symbols(it) } }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# CR 605.3: pay the ability's costs and add its mana immediately. Shared by priority and payment.
|
|
114
|
+
# `mana`: the mana chosen as it was activated (an option's "mana"). Restricted mana keeps its
|
|
115
|
+
# restriction in the pool (CR 106.6).
|
|
116
|
+
def activate_mana_ability!(index, id, ability_ref, mana = nil)
|
|
117
|
+
obj = object(id)
|
|
118
|
+
ability = chars(obj).ability(ability_ref) or raise ArgumentError, "#{id} has no ability #{ability_ref}"
|
|
119
|
+
chosen = mana && Mana.parse_produce(mana, [], "mana")
|
|
120
|
+
from = mana_provenance(id) # before a cost sacrifices it (a Treasure)
|
|
121
|
+
pay_from_pool!(index, ability.mana_cost, id) if ability.mana_cost
|
|
122
|
+
ability.costs.each { |cost| cost.pay!(self, obj) }
|
|
123
|
+
produced = chosen || ability.outcomes(self, obj).first # CR 605.3b: a fixed amount is counted as it resolves
|
|
124
|
+
add_mana!(index, produced, id, restriction: ability.spend_only&.data, from: from)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Whether the mana in the pool (unrestricted) can pay `cost` (a mana ability's mana cost, CR 605.3a).
|
|
128
|
+
def pool_covers?(index, cost)
|
|
129
|
+
covers?(cost, player(index).mana_pool.flat_map { |type, n| Array.new(n) { [type] } })
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Pays a mana ability's mana cost from the pool: each colored or hybrid symbol with mana that keeps
|
|
133
|
+
# the rest payable, then generic mana with colorless first, then the most plentiful color.
|
|
134
|
+
def pay_from_pool!(index, cost, source)
|
|
135
|
+
pool = player(index).mana_pool
|
|
136
|
+
units = pool.flat_map { |type, n| Array.new(n) { type } }
|
|
137
|
+
reqs = cost.requirements
|
|
138
|
+
spent = reqs.each_with_index.map do |req, i|
|
|
139
|
+
type = req.find do |t|
|
|
140
|
+
at = units.index(t) or next false
|
|
141
|
+
match_colored(reqs.drop(i + 1), units.dup.tap { it.delete_at(at) }.map { [it] })
|
|
142
|
+
end
|
|
143
|
+
units.delete_at(units.index(type))
|
|
144
|
+
type
|
|
145
|
+
end
|
|
146
|
+
cost.generic.times do
|
|
147
|
+
type = units.include?(:C) ? :C : units.tally.max_by { |_, n| n }.first
|
|
148
|
+
spent << units.delete_at(units.index(type))
|
|
149
|
+
end
|
|
150
|
+
spent.each do |type|
|
|
151
|
+
pool[type] -= 1
|
|
152
|
+
emit Events::ManaPaid.new(player: name(index), mana: "{#{type}}", object: source)
|
|
153
|
+
end
|
|
154
|
+
trim_pool_sources!(player(index)) # mana spent on a mana ability: its source no longer matters
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# ---- mana (CR 106) ----------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
# CR 106.4: mana goes into the pool. Mana with a spending restriction (CR 106.6), or that isn't lost as
|
|
160
|
+
# steps end (`keep_until: "end_of_combat"`, CR 702.189a), is kept apart, one entry per mana. Mana made by
|
|
161
|
+
# an artifact remembers its source ("if mana from a Treasure was spent", `from`: #mana_provenance): an
|
|
162
|
+
# entry's "from", or for pool mana one PlayerState#pool_sources entry per mana.
|
|
163
|
+
def add_mana!(index, produce, source, restriction: nil, keep_until: nil, from: mana_provenance(source))
|
|
164
|
+
return if produce.empty?
|
|
165
|
+
|
|
166
|
+
pl = player(index)
|
|
167
|
+
if restriction || keep_until
|
|
168
|
+
entry = { "spend_only" => restriction && Snapshot.deep_copy(restriction), "until" => keep_until, "from" => from }.compact
|
|
169
|
+
produce.each { pl.restricted_mana << { "mana" => it.to_s }.merge(entry) }
|
|
170
|
+
else
|
|
171
|
+
produce.each { |type| pl.mana_pool[type] += 1 }
|
|
172
|
+
produce.each { (pl.pool_sources ||= []) << { "mana" => it.to_s, "from" => from } } if from
|
|
173
|
+
end
|
|
174
|
+
emit Events::ManaAdded.new(player: name(index), mana: Mana.symbols(produce), source: source)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Pool mana of `type` that remembers no source (CR 106.6): the mana of that type beyond the remembered.
|
|
178
|
+
def unsourced_mana(player, type) = player.mana_pool[type.to_sym] - (player.pool_sources || []).count { it["mana"] == type.to_s }
|
|
179
|
+
|
|
180
|
+
# Keeps no more remembered sources of a type than there is mana of it in the pool.
|
|
181
|
+
def trim_pool_sources!(player)
|
|
182
|
+
return if player.pool_sources.nil? || player.pool_sources.empty?
|
|
183
|
+
|
|
184
|
+
kept = Hash.new(0)
|
|
185
|
+
player.pool_sources = player.pool_sources.select { (kept[it["mana"]] += 1) <= player.mana_pool[it["mana"].to_sym] }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Where mana comes from, when a spell may ask ("mana from a Treasure", CR 106.6): an artifact source that
|
|
189
|
+
# isn't a land, as it is now (it may be sacrificed for the mana). Nil for other sources.
|
|
190
|
+
def mana_provenance(source)
|
|
191
|
+
obj = state.objects[source] or return nil
|
|
192
|
+
c = chars(obj)
|
|
193
|
+
return nil unless c.type?(:artifact) && !c.land?
|
|
194
|
+
|
|
195
|
+
{ "name" => c.name, "types" => c.types.map(&:to_s), "subtypes" => c.subtypes.map(&:to_s),
|
|
196
|
+
"supertypes" => c.supertypes.map(&:to_s), "colors" => c.colors.map(&:to_s) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The characteristics a mana source had as it made the mana (#mana_provenance), to match a card spec.
|
|
200
|
+
def self.source_chars(from)
|
|
201
|
+
Enjine::Characteristics::FACE_DOWN.with(name: from["name"], types: from["types"].map(&:to_sym),
|
|
202
|
+
subtypes: from["subtypes"].map(&:to_sym), supertypes: from["supertypes"].map(&:to_sym),
|
|
203
|
+
colors: from["colors"].map(&:to_sym))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Mana that stays as `step` ends: "until end of combat" mana through the combat steps (CR 702.189a).
|
|
207
|
+
def self.mana_kept?(entry, step) = entry["until"] == "end_of_combat" && COMBAT_STEPS.include?(step)
|
|
208
|
+
COMBAT_STEPS = %i[beginning_of_combat declare_attackers declare_blockers combat_damage].freeze
|
|
209
|
+
|
|
210
|
+
# Whether such mana may be in the pool during `step` (it empties as end of combat ends).
|
|
211
|
+
def self.mana_held?(entry, step) = mana_kept?(entry, step) || (entry["until"] == "end_of_combat" && step == :end_of_combat)
|
|
212
|
+
|
|
213
|
+
def empty_mana_pools!
|
|
214
|
+
state.players.each do |pl|
|
|
215
|
+
kept = pl.restricted_mana.select { ManaSources.mana_kept?(it, state.step) }
|
|
216
|
+
lost = pl.restricted_mana - kept
|
|
217
|
+
next if pl.mana_pool.values.all?(&:zero?) && lost.empty?
|
|
218
|
+
|
|
219
|
+
text = pl.mana_pool.flat_map { |type, n| ["{#{type}}"] * n }.join + lost.map { "{#{it["mana"]}}" }.join
|
|
220
|
+
pl.mana_pool.transform_values! { 0 }
|
|
221
|
+
pl.restricted_mana = kept
|
|
222
|
+
pl.pool_sources = []
|
|
223
|
+
emit Events::ManaEmptied.new(player: pl.name, mana: text)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
include ManaSources
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Phasing (CR 702.26) isn't a zone change (702.26d): a phased-out permanent keeps its id, counters,
|
|
6
|
+
# tapped status, attachments and controller. It is moved from `state.battlefield` to the pseudo-zone
|
|
7
|
+
# `state.phased_out` (zone `:phased_out`), so every rule that looks at the battlefield treats it as
|
|
8
|
+
# though it doesn't exist (702.26b) without knowing about phasing. `GameObject#phased_out` records
|
|
9
|
+
# who controlled it as it phased out (for 502.1) and whether it phased out indirectly, attached to
|
|
10
|
+
# another permanent (702.26g).
|
|
11
|
+
module Phasing
|
|
12
|
+
# Phases out the permanents `ids` at the same time. Auras and Equipment attached to them phase out
|
|
13
|
+
# indirectly with them; one that would phase out both ways phases out indirectly (CR 702.26g-h).
|
|
14
|
+
def phase_out!(ids)
|
|
15
|
+
ids = ids.select { state.battlefield.include?(it) }
|
|
16
|
+
indirect = ids.flat_map { attached_tree(it) }
|
|
17
|
+
(ids - indirect).each do |id|
|
|
18
|
+
obj = object(id)
|
|
19
|
+
phase_out_one!(obj, false)
|
|
20
|
+
attached_tree(id).each { phase_out_one!(object(it), true) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Every permanent attached to `id`, and every permanent attached to those (CR 702.26g).
|
|
25
|
+
def attached_tree(id)
|
|
26
|
+
attachments_of(id).flat_map { [it, *attached_tree(it)] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def phase_out_one!(obj, indirectly)
|
|
30
|
+
return unless state.battlefield.delete(obj.id)
|
|
31
|
+
|
|
32
|
+
obj.zone = :phased_out
|
|
33
|
+
obj.phased_out = { "by" => obj.controller, "indirectly" => indirectly }
|
|
34
|
+
state.phased_out << obj.id
|
|
35
|
+
leave_combat!(obj.id) # CR 506.4: removed from combat
|
|
36
|
+
end_while_source_effects!(obj.id) # CR 702.26f: "for as long as" effects can no longer see it
|
|
37
|
+
emit Events::PhasedOut.new(object: obj.id, indirectly: indirectly)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# CR 502.1, 702.26a: simultaneously, the phased-in permanents with phasing that `index` controls phase
|
|
41
|
+
# out, and the permanents that phased out directly under `index`'s control phase in, with whatever
|
|
42
|
+
# phased out indirectly attached to them (CR 702.26g). The set phasing in is fixed first, so nothing
|
|
43
|
+
# phases out and back in at once. Nothing phases in while an effect says permanents can't phase in.
|
|
44
|
+
def phasing_step!(index)
|
|
45
|
+
coming = phasing_in_prevented? ? [] : state.phased_out.select { phases_in_for?(object(it), index) }
|
|
46
|
+
phase_out!(state.battlefield.select { object(it).controller == index && chars(it).keyword?(:phasing) })
|
|
47
|
+
coming.each { phase_in!(object(it)) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def phases_in_for?(obj, index) = obj.phased_out["by"] == index && !obj.phased_out["indirectly"]
|
|
51
|
+
|
|
52
|
+
def phasing_in_prevented?
|
|
53
|
+
state.battlefield.any? { |id| chars(id).abilities.any? { it.ability.kind == :cant_phase_in } }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# CR 702.26c, 702.26i: it phases in attached to what it was attached to if that is still on the
|
|
57
|
+
# battlefield (phased in or not), else unattached; state-based actions deal with the rest.
|
|
58
|
+
def phase_in!(obj)
|
|
59
|
+
state.phased_out.delete(obj.id)
|
|
60
|
+
indirectly = obj.phased_out["indirectly"]
|
|
61
|
+
obj.zone = :battlefield
|
|
62
|
+
obj.phased_out = nil
|
|
63
|
+
state.battlefield << obj.id
|
|
64
|
+
to = obj.attached_to
|
|
65
|
+
obj.attached_to = nil if !indirectly && to && !state.battlefield.include?(to) && !state.phased_out.include?(to)
|
|
66
|
+
emit Events::PhasedIn.new(object: obj.id)
|
|
67
|
+
state.phased_out.select { object(it).attached_to == obj.id && object(it).phased_out["indirectly"] }
|
|
68
|
+
.each { phase_in!(object(it)) }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
include Phasing
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Starting the game (CR 103): choosing who plays first, mulligans (London mulligan,
|
|
6
|
+
# CR 103.5) and putting cards on the bottom.
|
|
7
|
+
module Pregame
|
|
8
|
+
def start!
|
|
9
|
+
state.decklists.each_with_index do |list, owner|
|
|
10
|
+
list.each { |card| state.players[owner].library << create_object(card, owner, :library).id }
|
|
11
|
+
end
|
|
12
|
+
emit Events::GameStarted.new(players: names, seed: state.seed)
|
|
13
|
+
winner = state.rng.rand(state.players.size)
|
|
14
|
+
emit Events::DieRolled.new(winner: name(winner))
|
|
15
|
+
ask!("kind" => "play_or_draw", "player" => winner)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# CR 103.1-103.4: starting player chosen, libraries shuffled, opening hands drawn.
|
|
19
|
+
def choose_starting_player!(chooser, play)
|
|
20
|
+
starting = play ? chooser : state.opponent(chooser)
|
|
21
|
+
state.starting_player = starting
|
|
22
|
+
emit Events::StartingPlayerChosen.new(chooser: name(chooser), choice: play ? "play" : "draw",
|
|
23
|
+
starting_player: name(starting))
|
|
24
|
+
turn_order.each do |i|
|
|
25
|
+
shuffle_library!(i)
|
|
26
|
+
HAND_SIZE.times { draw!(i) }
|
|
27
|
+
end
|
|
28
|
+
ask!("kind" => "mulligan", "player" => starting)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Player indexes in turn order, starting with the starting player.
|
|
32
|
+
def turn_order
|
|
33
|
+
first = state.starting_player || 0
|
|
34
|
+
Array.new(state.players.size) { |i| (first + i) % state.players.size }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# CR 103.5 (London mulligan): players declare in turn order, then all mulligans happen at once.
|
|
38
|
+
def declare_mulligan!(index, choice)
|
|
39
|
+
player(index).mulligan_choice = choice
|
|
40
|
+
emit Events::MulliganDeclared.new(player: name(index), choice: choice)
|
|
41
|
+
undecided = turn_order.find { |i| !player(i).kept && player(i).mulligan_choice.nil? }
|
|
42
|
+
return ask!("kind" => "mulligan", "player" => undecided) if undecided
|
|
43
|
+
|
|
44
|
+
resolve_mulligan_round!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# CR 103.5: all declared mulligans happen at the same time. Taking a mulligan is:
|
|
48
|
+
# shuffle the hand away, draw a new hand, then put N cards (N = mulligans taken so
|
|
49
|
+
# far) on the bottom. Only then does the next round of declarations start.
|
|
50
|
+
def resolve_mulligan_round!
|
|
51
|
+
turn_order.each do |i|
|
|
52
|
+
pl = player(i)
|
|
53
|
+
if pl.mulligan_choice == "keep"
|
|
54
|
+
pl.kept = true
|
|
55
|
+
emit Events::HandKept.new(player: pl.name, mulligans: pl.mulligans)
|
|
56
|
+
elsif pl.mulligan_choice == "mulligan"
|
|
57
|
+
take_mulligan!(i)
|
|
58
|
+
end
|
|
59
|
+
pl.mulligan_choice = nil
|
|
60
|
+
end
|
|
61
|
+
next_bottom_or_declaration!
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def take_mulligan!(index)
|
|
65
|
+
pl = player(index)
|
|
66
|
+
pl.mulligans += 1
|
|
67
|
+
pl.hand.dup.each { |id| move!(id, :library, reason: "mulligan") }
|
|
68
|
+
shuffle_library!(index)
|
|
69
|
+
HAND_SIZE.times { draw!(index) }
|
|
70
|
+
emit Events::MulliganTaken.new(player: pl.name, mulligans: pl.mulligans)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Bottoms owed from this round's mulligans (in turn order), then the next round of
|
|
74
|
+
# declarations, then the first turn.
|
|
75
|
+
def next_bottom_or_declaration!
|
|
76
|
+
index = turn_order.find { |i| bottom_remaining(i).positive? }
|
|
77
|
+
return ask!("kind" => "bottom", "player" => index, "count" => bottom_remaining(index)) if index
|
|
78
|
+
|
|
79
|
+
next_player = turn_order.find { |i| !player(i).kept }
|
|
80
|
+
return ask!("kind" => "mulligan", "player" => next_player) if next_player
|
|
81
|
+
|
|
82
|
+
ask!(nil)
|
|
83
|
+
begin_turn!(state.starting_player)
|
|
84
|
+
advance!
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def bottom_remaining(index)
|
|
88
|
+
pl = player(index)
|
|
89
|
+
[pl.hand.size - (HAND_SIZE - pl.mulligans), 0].max
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
include Pregame
|
|
94
|
+
end
|
|
95
|
+
end
|