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,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Decisions
|
|
5
|
+
CANCEL = { type: "cancel" }.freeze
|
|
6
|
+
|
|
7
|
+
# CR 734.1: a cast or activation can be rewound ("cancel") until something irreversible has been
|
|
8
|
+
# paid (Engine::Payment#cancellable?); a triggered ability being put on the stack never can.
|
|
9
|
+
def self.with_cancel(engine, options) = engine.cancellable? ? options + [CANCEL] : options
|
|
10
|
+
|
|
11
|
+
# CR 601.2b / 603.3c: choose a mode of a modal spell or ability, one per decision.
|
|
12
|
+
class ChooseMode < Base
|
|
13
|
+
def self.doc
|
|
14
|
+
'Choose a mode of a modal ("choose one") spell or ability: `{type: "mode", mode: index}` ' \
|
|
15
|
+
'(0-based, in printed order); `{type: "done"}` once enough are chosen when more are allowed; ' \
|
|
16
|
+
'`{type: "cancel"}` rewinds a cast. `choose` modes in total (at least `min`).'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.options(engine, _pending) = Decisions.with_cancel(engine, engine.mode_options)
|
|
20
|
+
|
|
21
|
+
def self.submit(engine, _pending, option)
|
|
22
|
+
case option[:type]
|
|
23
|
+
when "cancel" then engine.cancel_casting!
|
|
24
|
+
when "done" then engine.modes_done!
|
|
25
|
+
else engine.choose_mode!(option[:mode])
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
register("choose_mode", ChooseMode)
|
|
30
|
+
|
|
31
|
+
# CR 601.2b / 702.33a: announce whether an optional additional cost (kicker) will be paid.
|
|
32
|
+
class ChooseKicker < Base
|
|
33
|
+
def self.doc
|
|
34
|
+
'While casting a spell with kicker: `{type: "kick"}` pays the kicker cost too, `{type: "no_kick"}` ' \
|
|
35
|
+
"doesn't; `{type: \"cancel\"}` rewinds. `cost` describes the kicker cost."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.options(engine, _pending) = Decisions.with_cancel(engine, engine.kicker_options)
|
|
39
|
+
|
|
40
|
+
def self.submit(engine, _pending, option)
|
|
41
|
+
option[:type] == "cancel" ? engine.cancel_casting! : engine.announce_kicker!(option[:type] == "kick")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
register("choose_kicker", ChooseKicker)
|
|
45
|
+
|
|
46
|
+
# CR 601.2b: announce which of two or more costs ("behold a Goblin or pay {2}") will be paid.
|
|
47
|
+
class ChooseCostOption < Base
|
|
48
|
+
def self.doc
|
|
49
|
+
'While casting/activating with a choice between costs (cost `one_of`): `{type: "cost_option", option: i}` ' \
|
|
50
|
+
"(0-based, in the order of `costs`) for one that could be paid; `{type: \"cancel\"}` rewinds."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.options(engine, _pending) = Decisions.with_cancel(engine, engine.cost_option_options)
|
|
54
|
+
|
|
55
|
+
def self.submit(engine, _pending, option)
|
|
56
|
+
option[:type] == "cancel" ? engine.cancel_casting! : engine.choose_cost_option!(option[:option])
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
register("choose_cost_option", ChooseCostOption)
|
|
60
|
+
|
|
61
|
+
# CR 601.2b / 107.3a: announce the value of X for a cost with {X}.
|
|
62
|
+
class ChooseX < Base
|
|
63
|
+
def self.doc
|
|
64
|
+
'While casting/activating a spell or ability with {X} in its cost: `{type: "x", value: n}` ' \
|
|
65
|
+
'announces X (0 up to `max`, the most the available mana could pay for); `{type: "cancel"}` rewinds.'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.options(engine, pending) = Decisions.with_cancel(engine, (0..pending["max"]).map { { type: "x", value: it } })
|
|
69
|
+
|
|
70
|
+
def self.submit(engine, _pending, option)
|
|
71
|
+
option[:type] == "cancel" ? engine.cancel_casting! : engine.announce_x!(option[:value])
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
register("choose_x", ChooseX)
|
|
75
|
+
|
|
76
|
+
# CR 601.2c: a variable number of targets ("X target", "any number of target", "up to N") is announced
|
|
77
|
+
# before the targets are chosen.
|
|
78
|
+
class ChooseTargetCount < Base
|
|
79
|
+
def self.doc
|
|
80
|
+
'While casting/activating: `{type: "count", count: n}` announces how many targets (from `min` to ' \
|
|
81
|
+
'`max`, at most as many as there are legal ones) target ##`index` + 1 will have; `{type: "cancel"}` rewinds.'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.options(engine, _pending) = Decisions.with_cancel(engine, engine.target_count_options)
|
|
85
|
+
|
|
86
|
+
def self.submit(engine, _pending, option)
|
|
87
|
+
option[:type] == "cancel" ? engine.cancel_casting! : engine.choose_target_count!(option[:count])
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
register("choose_target_count", ChooseTargetCount)
|
|
91
|
+
|
|
92
|
+
# CR 601.2c: choose the next target of the spell or ability being cast.
|
|
93
|
+
class ChooseTarget < Base
|
|
94
|
+
def self.doc
|
|
95
|
+
'While casting/activating: `{type: "target", target: ref}` for the next target (ref is an ' \
|
|
96
|
+
'object id or "player:<name>"); `{type: "done"}` chooses no more for an "up to" target; ' \
|
|
97
|
+
'`{type: "cancel"}` rewinds the whole cast.'
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.options(engine, _pending) = Decisions.with_cancel(engine, engine.target_options)
|
|
101
|
+
|
|
102
|
+
def self.submit(engine, _pending, option)
|
|
103
|
+
case option[:type]
|
|
104
|
+
when "cancel" then engine.cancel_casting!
|
|
105
|
+
when "done" then engine.choose_target!(nil)
|
|
106
|
+
else engine.choose_target!(option[:target])
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
register("choose_target", ChooseTarget)
|
|
111
|
+
|
|
112
|
+
# CR 601.2d: divide an amount (counters, damage) among the chosen targets, one unit at a time
|
|
113
|
+
# beyond the one each target must get.
|
|
114
|
+
class Distribute < Base
|
|
115
|
+
def self.doc
|
|
116
|
+
'While casting: `{type: "assign", target: ref}` gives one more of the divided amount to that target ' \
|
|
117
|
+
'(each target already has one); `{type: "cancel"}` rewinds. `left` is what remains to divide.'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.options(engine, _pending) = Decisions.with_cancel(engine, engine.distribute_options)
|
|
121
|
+
|
|
122
|
+
def self.submit(engine, _pending, option)
|
|
123
|
+
option[:type] == "cancel" ? engine.cancel_casting! : engine.distribute!(option[:target])
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
register("distribute", Distribute)
|
|
127
|
+
|
|
128
|
+
# CR 601.2h / 602.2b: a cost that needs a choice, e.g. which other creature to tap (CR 702.184a)
|
|
129
|
+
# or which creature to sacrifice. Tapping happens at once; other choices are paid as it becomes
|
|
130
|
+
# cast. Cancelling rewinds it.
|
|
131
|
+
class ChooseCost < Base
|
|
132
|
+
def self.doc
|
|
133
|
+
'While casting/activating: `{type: "cost", object: id}` picks the permanent (or card) a cost uses ' \
|
|
134
|
+
'(the creature to tap for station, the creature to sacrifice, the creature beamed up, ...); `{type: "done"}` ' \
|
|
135
|
+
"stops choosing for a cost of any number of permanents once enough are chosen (crew: total power N or " \
|
|
136
|
+
'more); `{type: "cancel"}` rewinds. `cost` describes the cost.'
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.options(engine, _pending)
|
|
140
|
+
options = engine.cost_choice_candidates.map { { type: "cost", **it } }
|
|
141
|
+
options << { type: "done" } if engine.cost_choice_finishable?
|
|
142
|
+
Decisions.with_cancel(engine, options)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.submit(engine, _pending, option)
|
|
146
|
+
case option[:type]
|
|
147
|
+
when "cancel" then engine.cancel_casting!
|
|
148
|
+
when "done" then engine.finish_cost_choice!
|
|
149
|
+
else engine.choose_cost!(option)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
register("choose_cost", ChooseCost)
|
|
154
|
+
|
|
155
|
+
# CR 601.2g-h: pay the mana cost one mana at a time. Mana abilities may be activated
|
|
156
|
+
# now; mana in the pool is spent one symbol per decision. Also used for a cost paid while a
|
|
157
|
+
# spell or ability resolves (CR 118.12), where "cancel" declines to pay.
|
|
158
|
+
class PayCost < Base
|
|
159
|
+
def self.doc
|
|
160
|
+
'While casting/activating or paying a cost: `{type: "pay_mana", mana: "R"}` spends one mana from ' \
|
|
161
|
+
"the pool (`restricted: i`: restricted mana, CR 106.6; `from: i`: the pool mana an artifact made that " \
|
|
162
|
+
'pool_sources[i] names), `{type: "activate_mana", object: id, ability: ref}` ' \
|
|
163
|
+
'(plus `mana: "{G}{G}"` when the mana is chosen) taps a source, `{type: "cancel"}` ' \
|
|
164
|
+
"rewinds (or declines a payment asked for while resolving). Cancel isn't offered once a mana ability " \
|
|
165
|
+
"with a cost other than {T} (a Treasure) has been activated. `{type: \"convoke\", object: id}` taps a " \
|
|
166
|
+
"creature to pay for {1} or one mana of its color (a spell with convoke); `{type: \"improvise\", object: id}` " \
|
|
167
|
+
"taps an artifact to pay for {1} (a spell with improvise); `{type: \"waterbend\", object: id}` taps " \
|
|
168
|
+
"an artifact or creature to pay {1} of a waterbend cost. `remaining` shows what is left to pay."
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def self.options(engine, _pending)
|
|
172
|
+
Decisions.with_cancel(engine, engine.payable_mana_options + engine.payment_mana_options + engine.convoke_options +
|
|
173
|
+
engine.waterbend_options)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.submit(engine, _pending, option)
|
|
177
|
+
case option[:type]
|
|
178
|
+
when "cancel" then engine.cancel_payment!
|
|
179
|
+
when "pay_mana" then engine.pay_mana!(option[:mana], option[:restricted], option[:from])
|
|
180
|
+
when "convoke", "improvise" then engine.convoke!(option[:object], option[:type]) # CR 702.51a, 702.126a
|
|
181
|
+
when "waterbend" then engine.waterbend!(option[:object]) # CR 701.67a
|
|
182
|
+
else engine.tap_for_payment!(option[:object], option[:ability], option[:mana])
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
register("pay_cost", PayCost)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Decisions
|
|
5
|
+
DONE = { type: "done" }.freeze
|
|
6
|
+
|
|
7
|
+
# CR 508.1: the active player declares attackers one at a time, then `done`.
|
|
8
|
+
class DeclareAttackers < Base
|
|
9
|
+
def self.doc
|
|
10
|
+
'Declare one attacker: `{type: "attack", object: id, target: ref}` (the defending player ' \
|
|
11
|
+
'or one of their planeswalkers), or `{type: "done"}` (withheld while a creature that attacks ' \
|
|
12
|
+
"each combat if able could still attack, CR 508.1d, or while a creature that can't attack alone " \
|
|
13
|
+
'attacks alone, CR 506.5; then `{type: "withdraw", object: id}` undoes an attack).'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.options(engine, _pending)
|
|
17
|
+
targets = engine.attack_targets
|
|
18
|
+
legal = engine.attack_declaration_legal?
|
|
19
|
+
done = engine.unmet_attack_requirements.empty? && legal ? [DONE] : [] # CR 508.1c-d
|
|
20
|
+
withdraw = legal ? [] : engine.attackers.keys.map { { type: "withdraw", object: it } }
|
|
21
|
+
done + engine.attack_candidates.flat_map { |id| targets.map { { type: "attack", object: id, target: it } } } + withdraw
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.submit(engine, _pending, option)
|
|
25
|
+
case option[:type]
|
|
26
|
+
when "done" then engine.finish_attackers!
|
|
27
|
+
when "withdraw" then engine.withdraw_attacker!(option[:object])
|
|
28
|
+
else engine.declare_attacker!(option[:object], option[:target])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
register("declare_attackers", DeclareAttackers)
|
|
33
|
+
|
|
34
|
+
# CR 509.1: the defending player declares blockers one at a time, then `done`.
|
|
35
|
+
class DeclareBlockers < Base
|
|
36
|
+
def self.doc
|
|
37
|
+
'Declare one blocker: `{type: "block", object: id, attacker: id}`, `{type: "done"}` (only when the ' \
|
|
38
|
+
'blocks are legal), or `{type: "unblock", object: id}` to fix an illegal menace block or free a ' \
|
|
39
|
+
"creature for an attacker that must be blocked if able (CR 509.1c)."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# "done" is offered only when the blocks are legal (menace, requirements); otherwise blocks can be removed.
|
|
43
|
+
def self.options(engine, _pending)
|
|
44
|
+
blocks = engine.block_options.map { |id, attacker| { type: "block", object: id, attacker: attacker } }
|
|
45
|
+
unblock = engine.menace_violations.flat_map { engine.blockers_of(it) } + engine.requirement_unblocks
|
|
46
|
+
removals = unblock.uniq.map { { type: "unblock", object: it } }
|
|
47
|
+
(engine.blocks_legal? ? [DONE] : []) + blocks + removals
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.submit(engine, _pending, option)
|
|
51
|
+
case option[:type]
|
|
52
|
+
when "done" then engine.finish_blockers!
|
|
53
|
+
when "unblock" then engine.remove_blocker!(option[:object])
|
|
54
|
+
else engine.declare_blocker!(option[:object], option[:attacker])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
register("declare_blockers", DeclareBlockers)
|
|
59
|
+
|
|
60
|
+
# CR 510.1c: assign one point of a multiply-blocked attacker's damage to one of its blockers.
|
|
61
|
+
class AssignDamage < Base
|
|
62
|
+
def self.doc
|
|
63
|
+
'Assign one point of combat damage of `attacker`: `{type: "assign", object: blocker_id}`. ' \
|
|
64
|
+
"`remaining` points are left."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.options(engine, pending)
|
|
68
|
+
engine.assignment_targets(pending["attacker"]).map { { type: "assign", object: it } }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.submit(engine, pending, option) = engine.assign_damage!(pending["attacker"], option[:object])
|
|
72
|
+
end
|
|
73
|
+
register("assign_damage", AssignDamage)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Decisions
|
|
5
|
+
# CR 514.1: during cleanup the active player discards down to maximum hand size,
|
|
6
|
+
# one card per decision.
|
|
7
|
+
class Discard < Base
|
|
8
|
+
def self.doc
|
|
9
|
+
'Cleanup step: discard one card, `{type: "discard", object: id}`, until the hand ' \
|
|
10
|
+
"has 7 cards. `count` is how many remain (CR 514.1)."
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.options(engine, pending)
|
|
14
|
+
engine.player(pending["player"]).hand.map { |id| { type: "discard", object: id } }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.submit(engine, pending, option)
|
|
18
|
+
engine.move!(option[:object], :graveyard, reason: "discard")
|
|
19
|
+
remaining = pending["count"] - 1
|
|
20
|
+
if remaining.positive?
|
|
21
|
+
engine.ask!(pending.merge("count" => remaining))
|
|
22
|
+
else
|
|
23
|
+
engine.turn_based_actions_done!
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
register("discard", Discard)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Decisions
|
|
5
|
+
# A choice an effect asks for while it resolves (scry, surveil, discard, ...). The pending
|
|
6
|
+
# decision carries a human-readable `prompt` and the exact `choices`.
|
|
7
|
+
class EffectChoice < Base
|
|
8
|
+
def self.doc
|
|
9
|
+
"A choice made while an effect resolves (scry, surveil, discard...): submit one of the " \
|
|
10
|
+
"`choices` exactly as listed; `prompt` describes it."
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.options(_engine, pending) = pending["choices"].map { it.transform_keys(&:to_sym) }
|
|
14
|
+
def self.submit(engine, _pending, option) = engine.effect_answer!(option)
|
|
15
|
+
|
|
16
|
+
# Which library card went where is hidden from other players; discards are public anyway.
|
|
17
|
+
def self.redact(option, viewer_is_decider) = viewer_is_decider ? option : option.except(:object)
|
|
18
|
+
end
|
|
19
|
+
register("effect_choice", EffectChoice)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Decisions
|
|
5
|
+
# An "as this enters" choice (CR 614.12), e.g. pay life or enter tapped, a number or a card name.
|
|
6
|
+
class EnterChoice < Base
|
|
7
|
+
def self.doc
|
|
8
|
+
'Before `object` enters: `{type: "pay_life", life: n}` (only with enough life) or ' \
|
|
9
|
+
'`{type: "enter_tapped"}` (CR 614.12, 119.4), or `{type: "behold", object: id}` (CR 701.4a); ' \
|
|
10
|
+
'`{type: "number", number: n}` for "choose a number"; ' \
|
|
11
|
+
'`{type: "name", name: "..."}` for "choose a card name" (CR 201.4); `{type: "color", color: "R"}`, ' \
|
|
12
|
+
'`{type: "creature_type", creature_type: "elf"}`, `{type: "card_type", card_type: "artifact"}`, ' \
|
|
13
|
+
'`{type: "basic_land_type", basic_land_type: "forest"}` or ' \
|
|
14
|
+
'`{type: "option", option: "word"}` for the other "choose a ..." abilities (CR 607.2d); devour picks permanents ' \
|
|
15
|
+
'to sacrifice one at a time, `{type: "devour", object: id}` (`chosen` lists them), then `{type: "done"}` ' \
|
|
16
|
+
"(CR 702.82a). With `effect: true` the permanent " \
|
|
17
|
+
"is being put onto the battlefield by a resolving effect (a token about to be created has `card`); " \
|
|
18
|
+
"with several entering at once, each is asked in turn before any enters (CR 614.12a-b)."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.options(engine, pending) = engine.entering_options(pending)
|
|
22
|
+
def self.submit(engine, _pending, option) = engine.enter_choice!(option)
|
|
23
|
+
end
|
|
24
|
+
register("enter_choice", EnterChoice)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Decisions
|
|
5
|
+
# CR 704.5j: choose which of your same-named legendary permanents to keep.
|
|
6
|
+
class LegendRule < Base
|
|
7
|
+
def self.doc
|
|
8
|
+
'Keep one of your legendary permanents named `name`: `{type: "keep", object: id}`; the others go ' \
|
|
9
|
+
"to the graveyard (CR 704.5j)."
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.options(engine, _pending)
|
|
13
|
+
frame = engine.state.frames.rfind { it["type"] == "legend_rule" }
|
|
14
|
+
engine.legend_candidates(frame).map { { type: "keep", object: it } }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.submit(engine, _pending, option) = engine.keep_legend!(option[:object])
|
|
18
|
+
end
|
|
19
|
+
register("legend_rule", LegendRule)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Decisions
|
|
5
|
+
# CR 103.5: keep the opening hand or take a (London) mulligan.
|
|
6
|
+
class Mulligan < Base
|
|
7
|
+
def self.doc
|
|
8
|
+
'Keep the hand (`{type: "keep"}`) or take a London mulligan (`{type: ' \
|
|
9
|
+
'"mulligan"}`). Players declare in turn order; mulligans then happen ' \
|
|
10
|
+
"simultaneously (CR 103.5)."
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
KEEP = { type: "keep" }.freeze
|
|
14
|
+
MULLIGAN = { type: "mulligan" }.freeze
|
|
15
|
+
|
|
16
|
+
def self.options(engine, pending)
|
|
17
|
+
engine.player(pending["player"]).mulligans < Engine::MAX_MULLIGANS ? [KEEP, MULLIGAN] : [KEEP]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.submit(engine, pending, option)
|
|
21
|
+
engine.declare_mulligan!(pending["player"], option[:type])
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
register("mulligan", Mulligan)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Decisions
|
|
5
|
+
# CR 603.3b: a player with several triggered abilities to put on the stack chooses the
|
|
6
|
+
# order, one per decision: the chosen trigger goes on the stack next (so it resolves later).
|
|
7
|
+
class OrderTriggers < Base
|
|
8
|
+
def self.doc
|
|
9
|
+
'Put one of your pending triggered abilities on the stack next: `{type: "trigger", trigger: id}` ' \
|
|
10
|
+
"(CR 603.3b). The last one put on the stack resolves first."
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.options(engine, pending)
|
|
14
|
+
engine.pending_triggers_for(pending["player"]).map { { type: "trigger", trigger: it["id"] } }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.submit(engine, _pending, option) = engine.choose_trigger!(option[:trigger])
|
|
18
|
+
end
|
|
19
|
+
register("order_triggers", OrderTriggers)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Decisions
|
|
5
|
+
# CR 103.1: the winner of the die roll chooses to play first or draw first.
|
|
6
|
+
class PlayOrDraw < Base
|
|
7
|
+
def self.doc = 'The die-roll winner chooses `{type: "play"}` or `{type: "draw"}` (CR 103.1).'
|
|
8
|
+
|
|
9
|
+
OPTIONS = [{ type: "play" }.freeze, { type: "draw" }.freeze].freeze
|
|
10
|
+
|
|
11
|
+
def self.options(_engine, _pending) = OPTIONS
|
|
12
|
+
|
|
13
|
+
def self.submit(engine, pending, option)
|
|
14
|
+
engine.choose_starting_player!(pending["player"], option[:type] == "play")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
register("play_or_draw", PlayOrDraw)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Decisions
|
|
5
|
+
# CR 117: the player with priority may take any legal action or pass.
|
|
6
|
+
# Options are the union of every registered priority action (Enjine::Actions).
|
|
7
|
+
class Priority < Base
|
|
8
|
+
def self.doc
|
|
9
|
+
"The player holding priority takes an action or passes. Options come from the priority actions below (CR 117)."
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.options(engine, pending)
|
|
13
|
+
Actions::PRIORITY.flat_map { |action| action.options(engine, pending["player"]) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.submit(engine, pending, option)
|
|
17
|
+
Actions.fetch(option[:type]).perform(engine, pending["player"], option)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
register("priority", Priority)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Decisions
|
|
5
|
+
# CR 502.3: in their untap step, the active player decides whether a permanent they "may choose not
|
|
6
|
+
# to untap" untaps. One decision per such tapped permanent (Engine::TurnStructure).
|
|
7
|
+
class UntapChoice < Base
|
|
8
|
+
def self.doc
|
|
9
|
+
'Untap step: `{type: "untap", object: id}` or `{type: "keep_tapped", object: id}` for a permanent you may ' \
|
|
10
|
+
"choose not to untap (CR 502.3)."
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.options(_engine, pending)
|
|
14
|
+
[{ type: "untap", object: pending["object"] }, { type: "keep_tapped", object: pending["object"] }]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.submit(engine, _pending, option) = engine.choose_untap!(option[:object], option[:type] == "untap")
|
|
18
|
+
end
|
|
19
|
+
register("untap_choice", UntapChoice)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module Frames
|
|
23
|
+
# The active player's untap-step choices, then the untap itself (CR 502.3).
|
|
24
|
+
class UntapChoice < Base
|
|
25
|
+
def self.step(engine, frame) = engine.step_untap_choice!(frame)
|
|
26
|
+
def self.decisions = %w[untap_choice]
|
|
27
|
+
|
|
28
|
+
def self.validate(frame, data, errors)
|
|
29
|
+
ids = Array(frame["pending"]) + Array(frame["keep"])
|
|
30
|
+
errors << "frames: untap_choice permanents must be on the battlefield" unless ids.all? { data["battlefield"].include?(it) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
register("untap_choice", UntapChoice)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# Every kind of pending decision. A decision kind is a class with:
|
|
5
|
+
# .options(engine, pending) -> Array of option Hashes (symbol keys, JSON-safe values)
|
|
6
|
+
# .submit(engine, pending, option)
|
|
7
|
+
# .redact(option, viewer_is_decider) -> what another viewer may see of the chosen option
|
|
8
|
+
module Decisions
|
|
9
|
+
KINDS = {} # rubocop:disable Style/MutableConstant -- extension registry
|
|
10
|
+
|
|
11
|
+
def self.register(kind, klass)
|
|
12
|
+
KINDS[kind] = klass
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.fetch(kind) = KINDS.fetch(kind) { raise ArgumentError, "unknown decision kind #{kind.inspect}" }
|
|
16
|
+
|
|
17
|
+
# Base class: options are public unless a subclass says otherwise.
|
|
18
|
+
class Base
|
|
19
|
+
def self.redact(option, _viewer_is_decider) = option
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/enjine/deck.rb
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# A player's deck: the card definitions it uses plus the main decklist, and optionally the cards the
|
|
5
|
+
# player owns outside the game.
|
|
6
|
+
#
|
|
7
|
+
# Deck.from_h({"cards" => [{...Forest...}], "main" => [{"card" => "Forest", "count" => 60}],
|
|
8
|
+
# "outside" => [{"card" => "Forest", "count" => 2}]})
|
|
9
|
+
#
|
|
10
|
+
# Constructed rules (CR 100.2a): at least 60 cards, at most 4 copies of any card
|
|
11
|
+
# that is not exempt (basic lands). An invalid deck can still be built and edited;
|
|
12
|
+
# it is simply not #valid?, so its player is not ready.
|
|
13
|
+
#
|
|
14
|
+
# Outside the game (CR 400.11, 400.11a): the engine doesn't track a match, so a deck carries the cards
|
|
15
|
+
# its player owns outside the game as `outside`: a constructed sideboard of at most 15 cards (CR 100.4a).
|
|
16
|
+
# The four-copy limit applies to the deck and the outside list together, and outside cards don't count
|
|
17
|
+
# toward the 60. Effects that bring a card into the game from outside it take the card from this list.
|
|
18
|
+
class Deck
|
|
19
|
+
MIN_SIZE = 60
|
|
20
|
+
MAX_COPIES = 4
|
|
21
|
+
MAX_OUTSIDE = 15
|
|
22
|
+
|
|
23
|
+
attr_reader :catalog
|
|
24
|
+
attr_writer :guard
|
|
25
|
+
|
|
26
|
+
def self.from_h(hash)
|
|
27
|
+
return hash if hash.is_a?(Deck)
|
|
28
|
+
raise InvalidDeck.new(["deck must be an object"]) unless hash.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
doc = hash.transform_keys(&:to_s).except("name", "description")
|
|
31
|
+
lists = %w[cards main outside].to_h { [it, doc.fetch(it, [])] }
|
|
32
|
+
lists.each { |key, list| raise InvalidDeck.new(["#{key}: must be a list"]) unless list.is_a?(Array) }
|
|
33
|
+
|
|
34
|
+
new(cards: lists["cards"], main: lists["main"], outside: lists["outside"])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(cards: [], main: [], outside: [])
|
|
38
|
+
@catalog = Catalog.new
|
|
39
|
+
cards.each { @catalog.add(it) }
|
|
40
|
+
@entries = {}
|
|
41
|
+
@outside = {}
|
|
42
|
+
@reference_errors = []
|
|
43
|
+
main.each_with_index { |entry, i| load_entry(entry, i, @entries, "main") }
|
|
44
|
+
outside.each_with_index { |entry, i| load_entry(entry, i, @outside, "outside") }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Ordered {id => count}.
|
|
48
|
+
def entries = @entries.dup.freeze
|
|
49
|
+
def size = @entries.values.sum
|
|
50
|
+
def count(card) = @entries.fetch(CardDefinition.slug(card), 0)
|
|
51
|
+
|
|
52
|
+
# Flat list of definition ids, one per physical card.
|
|
53
|
+
def card_ids = @entries.flat_map { |id, n| [id] * n }
|
|
54
|
+
|
|
55
|
+
# The cards outside the game: ordered {id => count}, their number, and one definition id per card.
|
|
56
|
+
def outside_entries = @outside.dup.freeze
|
|
57
|
+
def outside_size = @outside.values.sum
|
|
58
|
+
def outside_ids = @outside.flat_map { |id, n| [id] * n }
|
|
59
|
+
|
|
60
|
+
def errors
|
|
61
|
+
errs = @reference_errors.dup
|
|
62
|
+
errs << "deck has #{size} cards; at least #{MIN_SIZE} are required (CR 100.2a)" if size < MIN_SIZE
|
|
63
|
+
errs << "outside: #{outside_size} cards; at most #{MAX_OUTSIDE} are allowed (CR 100.4a)" if outside_size > MAX_OUTSIDE
|
|
64
|
+
@entries.merge(@outside) { |_, a, b| a + b }.each do |id, n|
|
|
65
|
+
limit = @catalog.fetch(id).deck_limit # nil: any number (basic lands, "deck_limit": "any")
|
|
66
|
+
next if limit.nil? || n <= limit
|
|
67
|
+
|
|
68
|
+
errs << "#{@catalog.fetch(id).name}: #{n} copies; at most #{limit} are allowed (CR 100.2a#{", 100.4a" if @outside.key?(id)})"
|
|
69
|
+
end
|
|
70
|
+
errs
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def valid? = errors.empty?
|
|
74
|
+
|
|
75
|
+
# Adds a card definition this deck can then use.
|
|
76
|
+
def define(definition)
|
|
77
|
+
check_guard!
|
|
78
|
+
@catalog.add(definition)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def add(card, count: 1)
|
|
82
|
+
check_guard!
|
|
83
|
+
id = resolve!(card)
|
|
84
|
+
@entries[id] = @entries.fetch(id, 0) + positive!(count)
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Adds cards the player owns outside the game (CR 400.11a).
|
|
89
|
+
def add_outside(card, count: 1)
|
|
90
|
+
check_guard!
|
|
91
|
+
id = resolve!(card)
|
|
92
|
+
@outside[id] = @outside.fetch(id, 0) + positive!(count)
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def remove(card, count: 1)
|
|
97
|
+
check_guard!
|
|
98
|
+
id = resolve!(card)
|
|
99
|
+
have = @entries.fetch(id, 0)
|
|
100
|
+
raise InvalidDeck.new(["cannot remove #{count} #{card}: deck has #{have}"]) if have < positive!(count)
|
|
101
|
+
|
|
102
|
+
have == count ? @entries.delete(id) : @entries[id] = have - count
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Swaps cards between games; no limit on how many.
|
|
107
|
+
def swap(out:, in:, count: 1)
|
|
108
|
+
incoming = binding.local_variable_get(:in)
|
|
109
|
+
check_guard!
|
|
110
|
+
resolve!(incoming)
|
|
111
|
+
remove(out, count: count)
|
|
112
|
+
add(incoming, count: count)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def to_h
|
|
116
|
+
# Tokens aren't listed: the cards that create them bring them back (two tokens may share a name).
|
|
117
|
+
doc = { "cards" => @catalog.reject(&:token?).map(&:to_h), "main" => @entries.map { |id, n| { "card" => id, "count" => n } } }
|
|
118
|
+
doc["outside"] = @outside.map { |id, n| { "card" => id, "count" => n } } unless @outside.empty?
|
|
119
|
+
doc
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def load_entry(entry, index, into, list)
|
|
125
|
+
card, count = entry.is_a?(Hash) ? entry.transform_keys(&:to_s).values_at("card", "count") : []
|
|
126
|
+
unless card.is_a?(String) && count.is_a?(Integer) && count.positive?
|
|
127
|
+
return @reference_errors << "#{list}[#{index}]: must be {\"card\": name, \"count\": positive integer}"
|
|
128
|
+
end
|
|
129
|
+
return @reference_errors << "#{list}[#{index}].card: #{card.inspect} is not defined in cards" unless @catalog.include?(card)
|
|
130
|
+
|
|
131
|
+
id = CardDefinition.slug(card)
|
|
132
|
+
into[id] = into.fetch(id, 0) + count
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def resolve!(card)
|
|
136
|
+
raise InvalidDeck.new(["card #{card.inspect} is not defined in this deck"]) unless @catalog.include?(card)
|
|
137
|
+
|
|
138
|
+
CardDefinition.slug(card)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def positive!(count)
|
|
142
|
+
raise ArgumentError, "count must be a positive integer" unless count.is_a?(Integer) && count.positive?
|
|
143
|
+
|
|
144
|
+
count
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def check_guard!
|
|
148
|
+
@guard&.call
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|