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,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Damage, life and counters (CR 120, 119, 122), and fighting (CR 701.14).
|
|
6
|
+
module Damage
|
|
7
|
+
# CR 120.3: results of damage depend on what receives it. `combat`: combat damage (CR 510.2).
|
|
8
|
+
# Returns the damage dealt. With `lifelink: false` the caller applies lifelink itself
|
|
9
|
+
# (simultaneous combat damage: one life gain event per source, CR 120.3f, 119.9).
|
|
10
|
+
def deal_damage!(source, ref, amount, combat: false, lifelink: true)
|
|
11
|
+
return 0 unless amount.positive?
|
|
12
|
+
|
|
13
|
+
data = propose("damage", { "source" => source, "target" => ref, "amount" => amount, "combat" => combat }).data
|
|
14
|
+
dealt = deal_proposed_damage!(source, data, combat, lifelink)
|
|
15
|
+
run_follow_ups!(data) # the rest of a prevention effect (CR 615.5)
|
|
16
|
+
dealt
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The damage event after replacement and prevention effects (CR 614, 615).
|
|
20
|
+
def deal_proposed_damage!(source, data, combat, lifelink)
|
|
21
|
+
amount = data["amount"]
|
|
22
|
+
return 0 unless amount.positive? # prevented (CR 615)
|
|
23
|
+
|
|
24
|
+
ref = data["target"] # redirected (CR 614.9)
|
|
25
|
+
|
|
26
|
+
has = %i[deathtouch lifelink wither].to_h { [it, damage_source_keyword?(source, it)] }
|
|
27
|
+
index = Mechanics::Filters.player_index(self, ref)
|
|
28
|
+
if index
|
|
29
|
+
emit Events::DamageDealt.new(source: source, target: ref, amount: amount, combat: combat)
|
|
30
|
+
change_life!(index, -amount) # CR 120.3a
|
|
31
|
+
else
|
|
32
|
+
return 0 unless state.battlefield.include?(ref)
|
|
33
|
+
|
|
34
|
+
obj = object(ref)
|
|
35
|
+
defn = chars(obj)
|
|
36
|
+
emit Events::DamageDealt.new(source: source, target: ref, amount: amount, combat: combat)
|
|
37
|
+
if defn.creature?
|
|
38
|
+
mark_creature_damage!(obj, source, has[:wither], amount)
|
|
39
|
+
obj.deathtouched = true if has[:deathtouch] # CR 702.2b
|
|
40
|
+
end
|
|
41
|
+
remove_counters!(obj, "loyalty", amount) if defn.planeswalker? # CR 120.3c
|
|
42
|
+
end
|
|
43
|
+
lifelink!(source, amount) if lifelink && has[:lifelink]
|
|
44
|
+
amount
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# CR 120.3d-e: damage to a creature is marked on it; from a source with wither it's -1/-1 counters
|
|
48
|
+
# put by the source's controller instead (CR 702.80a).
|
|
49
|
+
def mark_creature_damage!(obj, source, wither, amount)
|
|
50
|
+
return obj.damage += amount unless wither
|
|
51
|
+
|
|
52
|
+
add_counters!(obj, "-1/-1", amount, by_player: damage_source_controller(source))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# CR 120.6, 120.4a: damage that would be lethal to the creature `id` from `source`, counting damage already
|
|
56
|
+
# marked on it (1 if the source has deathtouch, CR 702.2c); a planeswalker's is its loyalty (CR 120.10).
|
|
57
|
+
# Nil for a player or an object that is neither.
|
|
58
|
+
def lethal_damage(source, id)
|
|
59
|
+
obj = state.objects[id]
|
|
60
|
+
return nil unless obj && state.battlefield.include?(id)
|
|
61
|
+
return loyalty(obj) if !chars(obj).creature? && chars(obj).planeswalker?
|
|
62
|
+
return nil unless chars(obj).creature?
|
|
63
|
+
|
|
64
|
+
lethal = [toughness(obj) - obj.damage, 0].max
|
|
65
|
+
damage_source_keyword?(source, :deathtouch) ? [lethal, 1].min : lethal
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# CR 120.4a, 120.10: the excess damage of `amount` dealt to `id`, given its lethal damage before.
|
|
69
|
+
def excess_damage(lethal, amount) = lethal ? [amount - lethal, 0].max : 0
|
|
70
|
+
|
|
71
|
+
# Damage dealt at the same time (combat damage, CR 510.2) is one event for prevention effects
|
|
72
|
+
# that apply once per event (a shield counter, CR 122.1c). Nil outside such a batch.
|
|
73
|
+
attr_reader :damage_batch
|
|
74
|
+
|
|
75
|
+
def simultaneous_damage
|
|
76
|
+
@damage_batch = []
|
|
77
|
+
yield
|
|
78
|
+
ensure
|
|
79
|
+
@damage_batch = nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether a damage source has `keyword` (deathtouch, lifelink, wither): a card (a permanent or a spell) as it
|
|
83
|
+
# is now; a source no longer in the zone it's expected to be in, as it last existed (CR 702.2e, 702.15c,
|
|
84
|
+
# 702.80b).
|
|
85
|
+
def damage_source_keyword?(source, keyword)
|
|
86
|
+
obj = state.objects[source]
|
|
87
|
+
return obj.kind == "card" && chars(obj).keyword?(keyword) if obj
|
|
88
|
+
|
|
89
|
+
lki = last_known(source) || resolving_last_known(source) || resolving_source_lki(source)
|
|
90
|
+
Array(lki&.[]("keywords")).include?(keyword.to_s)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The controller of a damage source, or as it last existed (CR 702.15b, 702.80a).
|
|
94
|
+
def damage_source_controller(source)
|
|
95
|
+
state.objects[source]&.controller ||
|
|
96
|
+
(last_known(source) || resolving_last_known(source) || resolving_source_lki(source))&.[]("controller")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# What the resolving ability remembers of its source, if `source` is that source (CR 608.2h).
|
|
100
|
+
def resolving_source_lki(source)
|
|
101
|
+
frame = state.frames.rfind { it["type"] == "resolve" }
|
|
102
|
+
ability = frame && state.objects[frame["object"]]
|
|
103
|
+
ability.memory&.[]("source_lki") if ability && !source.nil? && ability.source == source
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# CR 702.15b: damage dealt by a source with lifelink makes its controller gain that much life.
|
|
107
|
+
def lifelink!(source, amount)
|
|
108
|
+
controller = damage_source_controller(source) or return
|
|
109
|
+
change_life!(controller, amount)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def change_life!(index, delta)
|
|
113
|
+
return if delta.zero?
|
|
114
|
+
return if delta.positive? && player_rule?(index, "cant_gain_life") # CR 119.7: not even replaced
|
|
115
|
+
|
|
116
|
+
delta = propose("life", { "player" => index, "delta" => delta }).data["delta"]
|
|
117
|
+
return if delta.zero?
|
|
118
|
+
|
|
119
|
+
pl = player(index)
|
|
120
|
+
before = pl.life
|
|
121
|
+
pl.life += delta
|
|
122
|
+
emit Events::LifeChanged.new(player: pl.name, from: before, to: pl.life)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# `by_player`: the player putting the counters (CR 122.6a; default the permanent's controller).
|
|
126
|
+
def add_counters!(object, kind, amount, by_player: nil)
|
|
127
|
+
return unless amount.positive?
|
|
128
|
+
|
|
129
|
+
data = { "object" => object.id, "counter" => kind, "amount" => amount, "player" => by_player || object.controller }
|
|
130
|
+
amount = propose("counters", data).data["amount"]
|
|
131
|
+
return unless amount.positive?
|
|
132
|
+
|
|
133
|
+
before = object.counters.fetch(kind, 0)
|
|
134
|
+
object.counters[kind] = before + amount
|
|
135
|
+
# CR 613.7c: keyword counters of this kind get the new counter's timestamp (Engine::Characteristics#ability_layers).
|
|
136
|
+
# The next timestamp isn't taken: an effect created later gets this number too and sorts after it.
|
|
137
|
+
(object.memory["counter_timestamps"] ||= {})[kind] = state.next_id if Mechanics::Counters.keyword?(kind)
|
|
138
|
+
emit Events::CountersChanged.new(object: object.id, counter: kind, from: before, to: before + amount)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Counters on a player (CR 122.1): poison (ten or more lose the game, CR 122.1f, 704.5c), experience, rad, ...
|
|
142
|
+
def player_counters(index)
|
|
143
|
+
pl = player(index)
|
|
144
|
+
(pl.counters || {}).merge(pl.poison.positive? ? { "poison" => pl.poison } : {})
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def add_player_counters!(index, kind, amount)
|
|
148
|
+
return unless amount.positive?
|
|
149
|
+
|
|
150
|
+
pl = player(index)
|
|
151
|
+
before = player_counters(index).fetch(kind, 0)
|
|
152
|
+
kind == "poison" ? pl.poison += amount : (pl.counters ||= {})[kind] = before + amount
|
|
153
|
+
emit Events::PlayerCountersChanged.new(player: pl.name, counter: kind, from: before, to: before + amount)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def remove_counters!(object, kind, amount)
|
|
157
|
+
before = object.counters.fetch(kind, 0)
|
|
158
|
+
after = [before - amount, 0].max
|
|
159
|
+
return if after == before
|
|
160
|
+
|
|
161
|
+
object.counters[kind] = after
|
|
162
|
+
emit Events::CountersChanged.new(object: object.id, counter: kind, from: before, to: after)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# ---- damage and counters --------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
# All damage marked on a permanent is removed ("healed", CR 120.6, 514.2).
|
|
168
|
+
def remove_damage!(obj)
|
|
169
|
+
obj.damage = 0
|
|
170
|
+
obj.deathtouched = false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def remove_all_counters!(obj, kind = nil)
|
|
174
|
+
(kind ? [kind] : obj.counters.keys).each { remove_counters!(obj, it, obj.counters.fetch(it, 0)) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# CR 701.14a-d: each creature deals damage equal to its power to the other, at the same time; not
|
|
178
|
+
# combat damage. Neither deals damage unless both are still creatures on the battlefield (701.14b).
|
|
179
|
+
# A creature fighting itself deals twice its power to itself (701.14c). Returns the excess damage `one` dealt
|
|
180
|
+
# to `other` (CR 120.4a: "if excess damage is dealt to [it] this way"), 0 if they didn't fight.
|
|
181
|
+
def fight!(one, other)
|
|
182
|
+
return 0 unless [one, other].all? { state.battlefield.include?(it) && chars(it).creature? }
|
|
183
|
+
|
|
184
|
+
lethal = lethal_damage(one, other)
|
|
185
|
+
return excess_damage(lethal, deal_damage!(one, one, 2 * power(object(one)))) if one == other
|
|
186
|
+
|
|
187
|
+
powers = [power(object(one)), power(object(other))]
|
|
188
|
+
dealt = deal_damage!(one, other, powers[0])
|
|
189
|
+
deal_damage!(other, one, powers[1])
|
|
190
|
+
excess_damage(lethal, dealt)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
include Damage
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Player designations that last for the rest of the game (PlayerState#designations), such as an enduring
|
|
6
|
+
# story (CR 702.195b). They have no rules meaning of their own; conditions read them
|
|
7
|
+
# (`{"designation": "enduring_story"}`, Mechanics::Condition::Designation).
|
|
8
|
+
module Designations
|
|
9
|
+
NAMES = %w[enduring_story].freeze
|
|
10
|
+
|
|
11
|
+
def designation?(index, designation) = player(index).designations.to_a.include?(designation)
|
|
12
|
+
|
|
13
|
+
def gain_designation!(index, designation)
|
|
14
|
+
return if designation?(index, designation)
|
|
15
|
+
|
|
16
|
+
(player(index).designations ||= []) << designation
|
|
17
|
+
emit Events::DesignationGained.new(player: name(index), designation: designation)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Storied (CR 702.195a): "Any time you control three or more permanents that are artifacts, Sagas, and/or
|
|
21
|
+
# legendary and you don't have an enduring story, you have an enduring story for the rest of the game."
|
|
22
|
+
# Checked whenever state-based actions are, so before triggered abilities are put on the stack
|
|
23
|
+
# (CR 702.195c). Returns true when a player got one.
|
|
24
|
+
def tell_enduring_stories!
|
|
25
|
+
return false unless storied_card?
|
|
26
|
+
|
|
27
|
+
told = state.players.each_index.select do |i|
|
|
28
|
+
mine = state.battlefield.filter_map { (obj = object(it)).controller == i ? chars(obj) : nil }
|
|
29
|
+
!designation?(i, "enduring_story") && mine.any? { it.keyword?(:storied) } && mine.count { story_piece?(it) } >= 3
|
|
30
|
+
end
|
|
31
|
+
told.each { gain_designation!(it, "enduring_story") }
|
|
32
|
+
!told.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def story_piece?(chars) = chars.type?(:artifact) || chars.legendary? || chars.subtypes.include?(:saga)
|
|
38
|
+
|
|
39
|
+
def storied_card?
|
|
40
|
+
@storied_card = !catalog.find { it.keyword?(:storied) }.nil? unless defined?(@storied_card)
|
|
41
|
+
@storied_card
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
include Designations
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Emblems (CR 114): objects in the command zone with abilities and no other characteristics. An emblem
|
|
6
|
+
# is a GameObject of kind "emblem" whose `card` is the card whose effect created it and whose `ability`
|
|
7
|
+
# is that effect's reference; its abilities are part of that card's data (Effects::CreateEmblem), so it
|
|
8
|
+
# needs no card of its own. It never leaves the command zone. Its abilities function there (CR 114.4):
|
|
9
|
+
# triggered abilities (Engine::Triggers#trigger_sources), static abilities (#static_sources) and
|
|
10
|
+
# player rules (#player_rule_statics).
|
|
11
|
+
module Emblems
|
|
12
|
+
# CR 114.1, 114.2: `index` gets an emblem; returns it.
|
|
13
|
+
def create_emblem!(index, card, ref)
|
|
14
|
+
obj = create_object(card, index, :command)
|
|
15
|
+
obj.kind = "emblem"
|
|
16
|
+
obj.ability = ref
|
|
17
|
+
state.command << obj.id
|
|
18
|
+
emit Events::EmblemCreated.new(player: name(index), object: obj.id, card: card)
|
|
19
|
+
obj
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# CR 114.3: an emblem has no characteristics other than its abilities. They are referenced as granted
|
|
23
|
+
# abilities ("granted:<card>:<ref>:<j>"), so a triggered one resolves from the creating card's data.
|
|
24
|
+
def emblem_characteristics(obj)
|
|
25
|
+
abilities = catalog.fetch(obj.card).effect_granted(obj.ability).map do |inst|
|
|
26
|
+
AbilityInstance.new(ref: "granted:#{obj.card}:#{inst.ref}", ability: inst.ability)
|
|
27
|
+
end
|
|
28
|
+
Enjine::Characteristics::FACE_DOWN.with(name: "Emblem", abilities: abilities.freeze)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The emblems in the command zone, as objects.
|
|
32
|
+
def emblems = (state.command || []).map { object(it) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
include Emblems
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# "As this enters" choices (CR 614.12), the status a permanent enters with (Engine#plan_move), and
|
|
6
|
+
# permanents a resolving effect puts onto the battlefield.
|
|
7
|
+
module Entering
|
|
8
|
+
# Counters from the caller ("with a +1/+1 counter", CR 701.72b) add to the ones replacements gave it.
|
|
9
|
+
def merge_enters!(status, extra)
|
|
10
|
+
extra.each do |key, value|
|
|
11
|
+
status[key] = key == "counters" ? status.fetch(key, {}).merge(value) { |_, a, b| a + b } : value
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The "as this enters" choices the controller must make before `id` enters (CR 614.12), in printed order
|
|
16
|
+
# (Multiversal Passage: a basic land type, then pay 2 life or enter tapped). `id` may also be an object (a
|
|
17
|
+
# stand-in), name a land played from outside the game, or a token an effect is about to create (see
|
|
18
|
+
# #token_key).
|
|
19
|
+
def entering_choices(id)
|
|
20
|
+
id = outside_object(nil, outside_card(id)) if outside_key?(id)
|
|
21
|
+
abilities = if id.is_a?(GameObject) || state.objects.key?(id) then chars(id).abilities
|
|
22
|
+
else token_to_create(id).printed_characteristics.abilities
|
|
23
|
+
end
|
|
24
|
+
abilities.map(&:ability).select { it.kind == :entering_choice }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def entering_choice(id) = entering_choices(id).first
|
|
28
|
+
|
|
29
|
+
# The answers given so far, one per choice in order (a single answer from an older snapshot counts as one).
|
|
30
|
+
def self.answers(given) = given.is_a?(Hash) ? [given] : Array(given)
|
|
31
|
+
|
|
32
|
+
# Whether `id` still has an "as this enters" choice to make, given the answers so far.
|
|
33
|
+
def entering_choice_pending?(id, given) = entering_choices(id).size > Entering.answers(given).size
|
|
34
|
+
|
|
35
|
+
# Records the answer to an enter_choice decision on the frame that asked, or, for a permanent an
|
|
36
|
+
# effect puts onto the battlefield, in that effect's locals (#ask_entering_choices!).
|
|
37
|
+
def enter_choice!(option)
|
|
38
|
+
answer = option.transform_keys(&:to_s)
|
|
39
|
+
# A choice of any number of permanents (devour): each pick asks again; "done" ends it (CR 614.12).
|
|
40
|
+
return ask!(state.pending.merge("chosen" => state.pending.fetch("chosen", []) + [answer["object"]])) if answer["type"] == "devour"
|
|
41
|
+
|
|
42
|
+
answer["objects"] = state.pending["chosen"] if state.pending.key?("chosen")
|
|
43
|
+
if state.pending["effect"]
|
|
44
|
+
answers = (answering_locals["entering"] ||= {})
|
|
45
|
+
id = state.pending["object"]
|
|
46
|
+
answers[id] = Entering.answers(answers[id]) + [answer.merge("player" => state.pending["player"])]
|
|
47
|
+
else
|
|
48
|
+
frame = state.frames.last
|
|
49
|
+
frame["enter"] = Entering.answers(frame["enter"]) + [answer]
|
|
50
|
+
end
|
|
51
|
+
ask!(nil)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ask_enter_choice!(id, controller)
|
|
55
|
+
ask!("kind" => "enter_choice", "player" => controller, "object" => id)
|
|
56
|
+
:wait
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def enters_from_answer(id, frame) = entering_statuses(id, Entering.answers(frame["enter"]))
|
|
60
|
+
|
|
61
|
+
# What a permanent enters with given its "as this enters" answers, combined (memories merged, counters
|
|
62
|
+
# added, tapped if any says so), or nil.
|
|
63
|
+
def entering_statuses(id, answers)
|
|
64
|
+
choices = entering_choices(id)
|
|
65
|
+
answers.each_with_index.reduce(nil) do |status, (answer, i)|
|
|
66
|
+
extra = choices[i] && entering_status(choices[i], answer)
|
|
67
|
+
next status || extra unless status && extra
|
|
68
|
+
|
|
69
|
+
status.merge(extra) do |key, mine, theirs|
|
|
70
|
+
next mine || theirs if key == "tapped"
|
|
71
|
+
next mine.merge(theirs) { |_, a, b| a + b } if key == "counters"
|
|
72
|
+
|
|
73
|
+
mine.is_a?(Hash) ? mine.merge(theirs) : theirs
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# What a permanent enters with given one "as this enters" answer (a memory, tapped, a copy, CR 707.5).
|
|
79
|
+
def entering_status(choice, answer)
|
|
80
|
+
answer = answer.except("player").transform_keys(&:to_sym)
|
|
81
|
+
choice.respond_to?(:enters_with) ? choice.enters_with(self, answer) : choice.enters_for(answer)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# What the "as this enters" answers cost, paid as the permanent enters: life (CR 119.4), the card
|
|
85
|
+
# beheld (CR 701.4a), the permanents it devours (CR 702.82a).
|
|
86
|
+
def pay_entering_costs!(controller, frame)
|
|
87
|
+
choices = entering_choices(frame["object"])
|
|
88
|
+
Entering.answers(frame["enter"]).each_with_index do |answer, i|
|
|
89
|
+
choices[i].pay!(self, controller, answer) if choices[i].respond_to?(:pay!)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ---- permanents an effect puts onto the battlefield (CR 614.12, 614.12a) ----------------
|
|
94
|
+
|
|
95
|
+
# Before any of them enters, the player who will control each permanent makes its "as this enters"
|
|
96
|
+
# choice, one permanent at a time. `entries`: [[object id or token key, controller], ...]. Answers
|
|
97
|
+
# stay in the effect's locals (Engine#enter_choice!), so the effect resumes after each. :wait while asking.
|
|
98
|
+
def ask_entering_choices!(locals, entries)
|
|
99
|
+
answers = locals["entering"] ||= {}
|
|
100
|
+
id, controller = entries.find { |ref, _| entering_choice_pending?(ref, answers[ref]) }
|
|
101
|
+
return nil unless id
|
|
102
|
+
|
|
103
|
+
token = state.objects.key?(id) ? {} : { "card" => token_to_create(id).id }
|
|
104
|
+
ask!({ "kind" => "enter_choice", "player" => controller, "object" => id, "effect" => true }.merge(token))
|
|
105
|
+
:wait
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Puts cards onto the battlefield at the same time: replacement effects for all of them are determined
|
|
109
|
+
# before any enters (CR 614.12), with the choices asked by #ask_entering_choices!. `entries`:
|
|
110
|
+
# [[id, controller, enters]]. Returns the new permanents.
|
|
111
|
+
def enter_battlefield!(locals, entries, reason:)
|
|
112
|
+
plans = entries.map do |id, controller, enters|
|
|
113
|
+
plan_move(id, :battlefield, reason: reason, controller: controller,
|
|
114
|
+
enters: with_entering_answer(locals, id, controller, enters))
|
|
115
|
+
end
|
|
116
|
+
plans.compact.map { execute_zone_change!(it) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The entering status `enters` plus what the "as this enters" answer for `id` adds (a memory, tapped);
|
|
120
|
+
# a life payment chosen is paid now, as it enters (CR 119.4).
|
|
121
|
+
def with_entering_answer(locals, id, controller, enters)
|
|
122
|
+
answers = locals.fetch("entering", {})[id] or return enters
|
|
123
|
+
pay_entering_costs!(controller, "enter" => answers, "object" => id)
|
|
124
|
+
extra = entering_statuses(id, Entering.answers(answers)) or return enters
|
|
125
|
+
(enters || {}).merge(extra) do |key, mine, theirs|
|
|
126
|
+
next mine || theirs if key == "tapped" # "enters tapped" from the effect stays
|
|
127
|
+
|
|
128
|
+
mine.is_a?(Hash) ? mine.merge(theirs) : theirs
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The options of a pending enter_choice: the first choice not answered yet. Several permanents entering at
|
|
133
|
+
# once can't be given choices whose combined life payments are more than the player has (CR 614.12b).
|
|
134
|
+
def entering_options(pending)
|
|
135
|
+
given = pending["effect"] ? answering_locals.fetch("entering", {})[pending["object"]] : state.frames.last["enter"]
|
|
136
|
+
choice = entering_choices(pending["object"])[Entering.answers(given).size]
|
|
137
|
+
return choice.pick_options(self, pending["player"], pending.fetch("chosen", [])) if choice.respond_to?(:pick_options)
|
|
138
|
+
|
|
139
|
+
options = choice.options(self, pending["player"])
|
|
140
|
+
return options unless pending["effect"]
|
|
141
|
+
|
|
142
|
+
spent = answering_locals.fetch("entering", {}).values.flat_map { Entering.answers(it) }.sum do
|
|
143
|
+
it["player"] == pending["player"] && it["type"] == "pay_life" ? it["life"] : 0
|
|
144
|
+
end
|
|
145
|
+
options.reject { it[:type] == "pay_life" && it[:life] > player(pending["player"]).life - spent }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A token an effect is about to create, named before it exists ("new_token:<card>:<n>") so its "as
|
|
149
|
+
# this enters" choice can be asked first (CR 614.12, 111.1).
|
|
150
|
+
def token_key(definition, index) = "new_token:#{definition.id}:#{index}"
|
|
151
|
+
def token_to_create(key) = catalog.fetch(key.to_s.split(":")[1].to_s)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
include Entering
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# The id prefix of a card exiled face down, so its id doesn't name it (CR 406.3).
|
|
6
|
+
FACE_DOWN_ID = "face_down"
|
|
7
|
+
|
|
8
|
+
# Cards exiled face down (CR 406.3, 406.3a-b). Such a card has no characteristics (Engine#chars) and
|
|
9
|
+
# only the players in its memory "may_look" see which card it is (View, Events::ZoneChanged). The effect
|
|
10
|
+
# that exiled it may let a player play it; the card is turned face up just before it's played, so
|
|
11
|
+
# whether it can be played is judged face up, and only for a player who may look at it.
|
|
12
|
+
module FaceDown
|
|
13
|
+
def face_down?(obj) = obj.zone == :exile && obj.memory["face_down"] == true
|
|
14
|
+
|
|
15
|
+
# Whether `index` may look at the face-down card (always true for a face-up card).
|
|
16
|
+
def may_look?(obj, index) = !face_down?(obj) || obj.memory.fetch("may_look", []).include?(index)
|
|
17
|
+
|
|
18
|
+
# Evaluates the block with the face-down card `id` seen face up (CR 406.3a: "turned face up just
|
|
19
|
+
# before the player announces that they are playing the card"). Nothing in the block changes state.
|
|
20
|
+
def face_up(id)
|
|
21
|
+
previous = @face_up
|
|
22
|
+
@face_up = id
|
|
23
|
+
yield
|
|
24
|
+
ensure
|
|
25
|
+
@face_up = previous
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
include FaceDown
|
|
29
|
+
end
|
|
30
|
+
end
|