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,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Objects and zones (CR 400): creating objects, moving them between zones through replacement
|
|
6
|
+
# effects (CR 614), tokens, drawing, shuffling and tapping; looking at and revealing cards
|
|
7
|
+
# (CR 701.20), putting cards on the bottom of a library (CR 401.4), cards "exiled with" a source
|
|
8
|
+
# (linked abilities, CR 607.2a, 406.6) and "until" exiles (CR 610.3).
|
|
9
|
+
module Zones
|
|
10
|
+
# Zone changes inside the block aren't results of the running effect (CR 400.7j names only
|
|
11
|
+
# what the effect's instruction moved, e.g. not "the rest" put into a graveyard).
|
|
12
|
+
def unrecorded
|
|
13
|
+
saved = @recorded
|
|
14
|
+
@recorded = nil
|
|
15
|
+
yield
|
|
16
|
+
ensure
|
|
17
|
+
@recorded = saved
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# CR 701.20a: every player sees the card; it stays where it is (701.20b).
|
|
21
|
+
def reveal!(index, ids)
|
|
22
|
+
ids.each { emit Events::CardRevealed.new(player: name(index), object: it, card: object(it).card) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# CR 701.20e: only the looking player (and the cards' owner) learn which cards they are.
|
|
26
|
+
def look_at!(index, owner, ids)
|
|
27
|
+
return if ids.empty?
|
|
28
|
+
|
|
29
|
+
emit Events::LookedAt.new(player: name(index), owner: name(owner), objects: ids.dup,
|
|
30
|
+
cards: ids.map { object(it).card })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Puts library cards (already in `index`'s library) on its bottom, the first id highest (CR 401.4).
|
|
34
|
+
# `random`: the order is randomized with the game's RNG, so replays stay deterministic.
|
|
35
|
+
def put_on_bottom!(index, ids, random: false)
|
|
36
|
+
return if ids.empty?
|
|
37
|
+
|
|
38
|
+
ids = ids.dup
|
|
39
|
+
state.rng.shuffle!(ids) if random
|
|
40
|
+
library = player(index).library
|
|
41
|
+
library.replace((library - ids) + ids)
|
|
42
|
+
emit Events::LibraryReordered.new(player: name(index), top: 0, bottom: ids.size)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# CR 607.2a: cards an ability of `source` exiled, for its linked abilities ("exiled with it").
|
|
46
|
+
def link_exiled!(source, id)
|
|
47
|
+
obj = state.objects[source] or return
|
|
48
|
+
(obj.memory[Mechanics::Linked::EXILED] ||= []) << id
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# CR 610.3, 610.3c: once the source of an "exile ... until this leaves the battlefield" effect has
|
|
52
|
+
# left, the cards it exiled return to the battlefield under their owners' control. A token that left
|
|
53
|
+
# can't come back (CR 111.8).
|
|
54
|
+
def return_exiled_until_leaves!(old)
|
|
55
|
+
ids = old.memory[Mechanics::Linked::UNTIL_LEAVES] or return
|
|
56
|
+
unrecorded do
|
|
57
|
+
ids.each do |id|
|
|
58
|
+
card = state.objects[id]
|
|
59
|
+
next unless card && card.zone == :exile && !token?(card)
|
|
60
|
+
|
|
61
|
+
move!(id, :battlefield, reason: "return")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# ---- objects & zones (CR 400) ------------------------------------------
|
|
67
|
+
|
|
68
|
+
# A card exiled face down gets an id that doesn't name it (CR 406.3); a card's id names its front face.
|
|
69
|
+
def create_object(card, owner, zone, hidden: false)
|
|
70
|
+
id = "#{hidden ? FACE_DOWN_ID : card.split(CardDefinition::FACE_SEPARATOR).first}##{state.next_id}"
|
|
71
|
+
state.next_id += 1
|
|
72
|
+
state.objects[id] = GameObject.new(id: id, card: card, owner: owner, controller: owner, zone: zone,
|
|
73
|
+
tapped: false, controlled_since: nil, kind: "card", counters: {},
|
|
74
|
+
damage: 0, source: nil, ability: nil, targets: nil, loyalty_turn: nil,
|
|
75
|
+
deathtouched: false, modes: nil, memory: {}, attached_to: nil)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Moves an object to a zone as a new object (CR 400.7). Returns the new object.
|
|
79
|
+
# Proposes a state change, lets replacement effects modify it (CR 614), and returns
|
|
80
|
+
# the final proposal for the caller to execute.
|
|
81
|
+
# `applied`: replacement effects that already modified the event this one replaces (CR 614.5).
|
|
82
|
+
def propose(kind, data, applied = []) = Replacements.apply(self, Proposal.new(kind, data, applied.dup))
|
|
83
|
+
|
|
84
|
+
# `face_down`: exile it face down (CR 406.3); the list of player indices who may look at it.
|
|
85
|
+
def move!(id, to, reason:, position: :top, controller: nil, enters: nil, face_down: nil)
|
|
86
|
+
data = plan_move(id, to, reason: reason, position: position, controller: controller, enters: enters,
|
|
87
|
+
face_down: face_down)
|
|
88
|
+
data && execute_zone_change!(data)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The zone change `move!` would make, after replacement effects (CR 614), not yet executed; nil if a
|
|
92
|
+
# replacement cancelled it. Planning several before executing any lets them happen at once (CR 614.12).
|
|
93
|
+
def plan_move(id, to, reason:, position: :top, controller: nil, enters: nil, face_down: nil)
|
|
94
|
+
data = { "object" => id, "to" => to, "position" => position, "reason" => reason, "controller" => controller }
|
|
95
|
+
data["face_down"] = face_down if face_down && to == :exile
|
|
96
|
+
data["enters"] = { "tapped" => enters&.fetch("tapped", false) == true, "counters" => {} } if to == :battlefield
|
|
97
|
+
data["enters"]["face"] = enters["face"] if data["enters"] && enters&.key?("face") # seen by its own replacements
|
|
98
|
+
data = propose("zone_change", data).data
|
|
99
|
+
return nil if data["cancelled"] # e.g. a shield counter instead of being destroyed (CR 122.1c)
|
|
100
|
+
|
|
101
|
+
merge_enters!(data["enters"], enters.except("tapped")) if enters && data["enters"] # tapped: replaceable (614.12)
|
|
102
|
+
data
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Takes the object out of its zone; returns its last known information if it was a permanent.
|
|
106
|
+
def depart!(old)
|
|
107
|
+
lki = old.zone == :battlefield ? last_known_info(old) : nil # before it leaves (CR 603.10a)
|
|
108
|
+
remember_last_known!(old.id, lki) if lki # CR 608.2h (see Engine::TurnHistory)
|
|
109
|
+
remember_spell!(old) if old.zone == :stack && !old.ability? # a countered spell's mana spent (Engine::TurnHistory)
|
|
110
|
+
state.zone_list(old.zone, old.owner).delete(old.id)
|
|
111
|
+
state.objects.delete(old.id)
|
|
112
|
+
lki
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def execute_zone_change!(data)
|
|
116
|
+
id = data["object"]
|
|
117
|
+
to = data["to"]
|
|
118
|
+
old = object(id)
|
|
119
|
+
lki = depart!(old)
|
|
120
|
+
face_down = to == :exile ? data["face_down"] : nil
|
|
121
|
+
fresh = create_object(card_after_move(old, to), old.owner, to, hidden: !face_down.nil?) # front face up (Engine::Faces)
|
|
122
|
+
carry_copy_status!(fresh, old, to) # a token or copy stays one (Engine::Copies)
|
|
123
|
+
if old.zone == :battlefield
|
|
124
|
+
leave_combat!(id)
|
|
125
|
+
end_while_source_effects!(id) # CR 611.2b
|
|
126
|
+
end
|
|
127
|
+
arrive!(fresh, old, data)
|
|
128
|
+
fresh.memory.merge!("face_down" => true, "may_look" => face_down.uniq.sort) if face_down
|
|
129
|
+
list = state.zone_list(to, old.owner)
|
|
130
|
+
data["position"] == :bottom ? list.push(fresh.id) : list.unshift(fresh.id)
|
|
131
|
+
record_result!(fresh.id)
|
|
132
|
+
event = Events::ZoneChanged.new(card: old.card, old_id: id, new_id: fresh.id, owner: name(old.owner),
|
|
133
|
+
from: old.zone.to_s, to: to.to_s, reason: data["reason"],
|
|
134
|
+
lookers: face_down&.map { name(it) })
|
|
135
|
+
lki ? with_last_known(id, lki) { emit event } : emit(event)
|
|
136
|
+
return_exiled_until_leaves!(old) if lki # CR 610.3
|
|
137
|
+
unmeld!(old, to, data) if old.memory["melded"] # CR 713.8 (Engine::Faces)
|
|
138
|
+
prepare!(fresh) if to == :battlefield && data["enters"]["prepared"] # "enters prepared" (CR 723.3a)
|
|
139
|
+
run_follow_ups!(data, "object" => fresh.id) # "exile it instead. When you do, ..." (Engine::FollowUps)
|
|
140
|
+
fresh
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Status of the new object in its new zone (CR 110.2, 614.12 entering status).
|
|
144
|
+
def arrive!(fresh, old, data)
|
|
145
|
+
case data["to"]
|
|
146
|
+
when :battlefield
|
|
147
|
+
fresh.controller = data["controller"] || old.owner
|
|
148
|
+
fresh.controlled_since = state.turn
|
|
149
|
+
fresh.memory["entered"] = state.turn # "if it entered this turn" (power-up, CR 702.193a)
|
|
150
|
+
fresh.memory["cast"] = true if data["enters"]["cast"] # CR 400.7d: it remembers it was cast
|
|
151
|
+
fresh.memory.merge!(data["enters"]["memory"]) if data["enters"]["memory"] # CR 614.12: an "as this enters" choice
|
|
152
|
+
enter_face!(fresh, data["enters"]["face"]) if data["enters"]["face"] # CR 712.9, 712.11a (Engine::Faces)
|
|
153
|
+
enter_as_copy!(fresh, data["enters"]["copy"]) if data["enters"]["copy"] # CR 707.5 (Engine::Copies)
|
|
154
|
+
fresh.tapped = data["enters"]["tapped"]
|
|
155
|
+
fresh.counters.merge!(data["enters"]["counters"])
|
|
156
|
+
carry_from_stack!(fresh, old) if old.zone == :stack
|
|
157
|
+
fresh.attached_to = data["enters"]["attached_to"] # CR 608.3c: an Aura enters attached
|
|
158
|
+
enter_attacking!(fresh, data["enters"]["attacking"]) if data["enters"]["attacking"] # CR 508.4, 506.3a
|
|
159
|
+
when :stack
|
|
160
|
+
fresh.controller = data["controller"] || old.owner
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# A permanent spell becoming a permanent remembers how it was cast (CR 400.7d): its X (its ETB abilities
|
|
165
|
+
# use the spell's X, CR 107.3m), that it was kicked (CR 702.33d) and the mana spent to cast it (CR 601.2h).
|
|
166
|
+
def carry_from_stack!(fresh, old)
|
|
167
|
+
fresh.x = old.x
|
|
168
|
+
fresh.memory.merge!(old.memory.slice("kicked", "kicked_with", "gift")) # gift: CR 702.174b
|
|
169
|
+
fresh.mana_spent = old.mana_spent&.dup
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# CR 111.1: a token enters the battlefield under its creator's control. Entering replacement
|
|
173
|
+
# effects apply to it like to any permanent (CR 614.1c, 614.12).
|
|
174
|
+
# `memory`: a token that's a copy (Engine::Copies#create_token_copy!).
|
|
175
|
+
# A replacement may create several instead ("twice that many", CR 614.1a): the extra ones are part of the
|
|
176
|
+
# replaced event (`replaced`), so the replacement effects don't apply to them again (CR 614.5).
|
|
177
|
+
def create_token!(definition, controller, tapped: false, memory: nil, enters: nil, replaced: false)
|
|
178
|
+
definition = replace_token_creation!(definition, controller, tapped:, memory:, enters:) unless replaced
|
|
179
|
+
obj = create_object(definition.id, controller, :battlefield)
|
|
180
|
+
obj.memory.merge!(memory) if memory
|
|
181
|
+
entering = enters # an "as this enters" choice made before it's created (CR 614.12a)
|
|
182
|
+
tapped ||= entering&.dig("tapped") == true
|
|
183
|
+
enters = propose("zone_change", { "object" => obj.id, "to" => :battlefield, "controller" => controller,
|
|
184
|
+
"reason" => "create_token",
|
|
185
|
+
"enters" => { "tapped" => tapped, "counters" => {} } }).data["enters"]
|
|
186
|
+
merge_enters!(enters, entering.except("tapped")) if entering
|
|
187
|
+
obj.memory.merge!(enters["memory"]) if enters["memory"]
|
|
188
|
+
obj.controlled_since = state.turn
|
|
189
|
+
obj.memory["entered"] = state.turn
|
|
190
|
+
obj.tapped = enters["tapped"]
|
|
191
|
+
obj.counters.merge!(enters["counters"])
|
|
192
|
+
enter_as_copy!(obj, enters["copy"]) if enters["copy"] # CR 707.5 (Engine::Copies)
|
|
193
|
+
state.battlefield.unshift(obj.id)
|
|
194
|
+
enter_attacking!(obj) if enters["attacking"] # CR 508.4
|
|
195
|
+
record_result!(obj.id)
|
|
196
|
+
emit Events::ZoneChanged.new(card: definition.id, old_id: nil, new_id: obj.id, owner: name(controller),
|
|
197
|
+
from: "none", to: "battlefield", reason: "create_token")
|
|
198
|
+
prepare!(obj) if enters["prepared"] # CR 723.3a (Engine::Faces)
|
|
199
|
+
obj
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The create_token replacements (CR 614.1a): the token definition to create, after creating the extra
|
|
203
|
+
# ones a "twice that many" replacement adds.
|
|
204
|
+
def replace_token_creation!(definition, controller, memory:, **)
|
|
205
|
+
data = propose("create_token", { "player" => controller, "token" => definition.id, "count" => 1 }).data
|
|
206
|
+
definition = catalog.fetch(data["token"])
|
|
207
|
+
(data["count"] - 1).times do
|
|
208
|
+
create_token!(definition, controller, memory: memory && Snapshot.deep_copy(memory), replaced: true, **)
|
|
209
|
+
end
|
|
210
|
+
definition
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# CR 121.2: one card at a time. A replacement may turn one draw into several (CR 614.11); those
|
|
214
|
+
# draws are the modified event, so the replacement that made them doesn't apply again (CR 614.5).
|
|
215
|
+
def draw!(index, applied: [])
|
|
216
|
+
proposal = propose("draw", { "player" => index, "count" => 1 }, applied)
|
|
217
|
+
return unless proposal.data["player"]
|
|
218
|
+
return proposal.data["count"].times { draw!(index, applied: proposal.applied) } if proposal.data["count"] != 1
|
|
219
|
+
|
|
220
|
+
pl = player(index)
|
|
221
|
+
if pl.library.empty?
|
|
222
|
+
pl.drew_from_empty = true # CR 704.5b, checked as a state-based action
|
|
223
|
+
else
|
|
224
|
+
move!(pl.library.first, :hand, reason: "draw")
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def shuffle_library!(index)
|
|
229
|
+
state.rng.shuffle!(player(index).library)
|
|
230
|
+
emit Events::LibraryShuffled.new(player: name(index))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def tap!(object)
|
|
234
|
+
object.tapped = true
|
|
235
|
+
emit Events::Tapped.new(object: object.id)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# CR 701.26b: only tapped permanents can be untapped. Replacement effects (a stun counter,
|
|
239
|
+
# CR 122.1d) may stop it.
|
|
240
|
+
def untap!(object)
|
|
241
|
+
return unless object.tapped
|
|
242
|
+
return if propose("untap", { "object" => object.id }).data["cancelled"]
|
|
243
|
+
|
|
244
|
+
object.tapped = false
|
|
245
|
+
emit Events::Untapped.new(object: object.id)
|
|
246
|
+
end_while_source_effects!(object.id, %w[while_source_tapped]) # CR 611.2b: "for as long as it remains tapped" ends
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# CR 502.3: the active player's permanents untap, except those effects keep from untapping and those
|
|
250
|
+
# they chose to keep tapped (`keep`, see Engine::TurnStructure).
|
|
251
|
+
# An exerted permanent skips this one untap step (CR 701.43a).
|
|
252
|
+
def untap_all!(index, keep: [])
|
|
253
|
+
state.battlefield.each do |id|
|
|
254
|
+
obj = object(id)
|
|
255
|
+
next unless obj.controller == index
|
|
256
|
+
|
|
257
|
+
exerted = obj.memory.delete("exerted")
|
|
258
|
+
untap!(obj) unless keep.include?(id) || exerted || chars(obj).keyword?(:doesnt_untap)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# CR 701.43a-b: exerting it (again) means it won't untap during its controller's next untap step.
|
|
263
|
+
def exert!(obj) = obj.memory["exerted"] = true
|
|
264
|
+
|
|
265
|
+
# CR 302.6: a creature's {T} abilities need it under continuous control since
|
|
266
|
+
# the start of its controller's most recent turn.
|
|
267
|
+
def summoning_sick_for_tap?(object)
|
|
268
|
+
current = chars(object)
|
|
269
|
+
return false if !current.creature? || current.keyword?(:haste)
|
|
270
|
+
|
|
271
|
+
most_recent_turn = state.active == object.controller ? state.turn : state.turn - 1
|
|
272
|
+
object.controlled_since.nil? || object.controlled_since >= most_recent_turn
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
include Zones
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# The rules state machine.
|
|
5
|
+
#
|
|
6
|
+
# No Ruby call stack ever holds game progress: everything lives in State, so a
|
|
7
|
+
# game can be exported at any decision and resumed elsewhere. #advance! runs the
|
|
8
|
+
# game forward until a player must decide something (state.pending) or the game ends.
|
|
9
|
+
class Engine
|
|
10
|
+
HAND_SIZE = 7
|
|
11
|
+
MAX_MULLIGANS = 7
|
|
12
|
+
|
|
13
|
+
attr_accessor :state
|
|
14
|
+
attr_reader :catalog
|
|
15
|
+
|
|
16
|
+
def initialize(catalog, state, &emit)
|
|
17
|
+
@catalog = catalog
|
|
18
|
+
@state = state
|
|
19
|
+
@emit = emit
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def emit(event)
|
|
23
|
+
touch! if volatile_layers? || Characteristics::LAYER_EVENTS.include?(event.class)
|
|
24
|
+
record_history!(event) # before triggers look at it (see Engine::TurnHistory)
|
|
25
|
+
@emit.call(event)
|
|
26
|
+
watch_triggers!(event)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def name(index) = state.players[index].name
|
|
30
|
+
def names = state.players.map(&:name)
|
|
31
|
+
def player(index) = state.players[index]
|
|
32
|
+
def active_player = state.players[state.active]
|
|
33
|
+
def definition(object) = catalog.fetch(object.card)
|
|
34
|
+
def object(id) = state.objects.fetch(id)
|
|
35
|
+
def ask!(pending) = state.pending = pending
|
|
36
|
+
def step = Steps.fetch(state.step)
|
|
37
|
+
def attackers = state.attackers
|
|
38
|
+
|
|
39
|
+
# ---- decisions --------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def options
|
|
42
|
+
return [] unless state.pending
|
|
43
|
+
|
|
44
|
+
@options_memo = {}
|
|
45
|
+
Decisions.fetch(state.pending["kind"]).options(self, state.pending)
|
|
46
|
+
ensure
|
|
47
|
+
@options_memo = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def submit!(option)
|
|
51
|
+
Decisions.fetch(state.pending["kind"]).submit(self, state.pending, option)
|
|
52
|
+
advance!
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# Base class of every error raised by Enjine.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when an error carries a list of individual problems.
|
|
8
|
+
class ErrorList < Error
|
|
9
|
+
attr_reader :errors
|
|
10
|
+
|
|
11
|
+
def initialize(errors, prefix)
|
|
12
|
+
@errors = errors.freeze
|
|
13
|
+
super("#{prefix}:\n" + errors.map { |e| " - #{e}" }.join("\n"))
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A submitted option is not legal for the pending decision.
|
|
18
|
+
class IllegalDecision < Error
|
|
19
|
+
attr_reader :option, :legal_options, :index
|
|
20
|
+
|
|
21
|
+
def initialize(message, option: nil, legal_options: [], index: nil)
|
|
22
|
+
@option = option
|
|
23
|
+
@legal_options = legal_options
|
|
24
|
+
@index = index
|
|
25
|
+
super(index.nil? ? message : "decision ##{index}: #{message}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A card data block failed validation. #errors lists every problem with its path.
|
|
30
|
+
class InvalidCardDefinition < ErrorList
|
|
31
|
+
def initialize(errors, name: nil)
|
|
32
|
+
super(errors, "invalid card definition#{" (#{name})" if name}")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A deck document is malformed or references unknown cards.
|
|
37
|
+
class InvalidDeck < ErrorList
|
|
38
|
+
def initialize(errors)
|
|
39
|
+
super(errors, "invalid deck")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A player whose deck is not valid tried to take a seat.
|
|
44
|
+
class PlayerNotReady < ErrorList
|
|
45
|
+
def initialize(errors, name:)
|
|
46
|
+
super(errors, "player #{name} is not ready")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A snapshot could not be imported because it is inconsistent.
|
|
51
|
+
class InvalidSnapshot < ErrorList
|
|
52
|
+
def initialize(errors)
|
|
53
|
+
super(errors, "invalid snapshot")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Two definitions with the same id but different contents.
|
|
58
|
+
class CatalogConflict < Error; end
|
|
59
|
+
|
|
60
|
+
# A deck was edited while its player is seated in an unfinished game.
|
|
61
|
+
class DeckLocked < Error; end
|
|
62
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# Typed, serializable game events. Every state change emits one.
|
|
5
|
+
#
|
|
6
|
+
# Players are referenced by name. Each event knows how to present itself to a
|
|
7
|
+
# viewer (a player name or :observer); hidden information is redacted.
|
|
8
|
+
module Events
|
|
9
|
+
PUBLIC_ZONES = %w[battlefield graveyard stack exile].freeze
|
|
10
|
+
REGISTRY = {} # rubocop:disable Style/MutableConstant -- extension registry
|
|
11
|
+
|
|
12
|
+
# Behavior shared by every event class.
|
|
13
|
+
module Base
|
|
14
|
+
def type = self.class.event_type
|
|
15
|
+
|
|
16
|
+
def to_h = { "type" => type }.merge(super.transform_keys(&:to_s))
|
|
17
|
+
|
|
18
|
+
# Hash form for one viewer. Defaults to the full event.
|
|
19
|
+
def for_viewer(_viewer) = to_h
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.define(name, *fields, &)
|
|
23
|
+
klass = Data.define(*fields, &)
|
|
24
|
+
klass.include(Base)
|
|
25
|
+
klass.define_singleton_method(:event_type) { name.to_s }
|
|
26
|
+
REGISTRY[name.to_s] = klass
|
|
27
|
+
klass
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.from_h(hash)
|
|
31
|
+
hash = hash.transform_keys(&:to_s)
|
|
32
|
+
klass = REGISTRY.fetch(hash["type"]) { raise ArgumentError, "unknown event #{hash["type"].inspect}" }
|
|
33
|
+
klass.new(**klass.members.to_h { |m| [m, hash[m.to_s]] })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.zone_visible?(zone, owner, viewer)
|
|
37
|
+
viewer == :observer || PUBLIC_ZONES.include?(zone) || (zone == "hand" && viewer == owner)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
GameStarted = define(:game_started, :players, :seed)
|
|
41
|
+
DieRolled = define(:die_rolled, :winner)
|
|
42
|
+
StartingPlayerChosen = define(:starting_player_chosen, :chooser, :choice, :starting_player)
|
|
43
|
+
LibraryShuffled = define(:library_shuffled, :player)
|
|
44
|
+
MulliganDeclared = define(:mulligan_declared, :player, :choice)
|
|
45
|
+
MulliganTaken = define(:mulligan_taken, :player, :mulligans)
|
|
46
|
+
HandKept = define(:hand_kept, :player, :mulligans)
|
|
47
|
+
TurnBegan = define(:turn_began, :turn, :active)
|
|
48
|
+
StepBegan = define(:step_began, :turn, :step, :phase)
|
|
49
|
+
PriorityPassed = define(:priority_passed, :player)
|
|
50
|
+
Tapped = define(:tapped, :object)
|
|
51
|
+
Untapped = define(:untapped, :object)
|
|
52
|
+
# CR 702.26: phasing isn't a zone change; `indirectly`: it phased out attached to another permanent.
|
|
53
|
+
PhasedOut = define(:phased_out, :object, :indirectly)
|
|
54
|
+
PhasedIn = define(:phased_in, :object)
|
|
55
|
+
ManaAdded = define(:mana_added, :player, :mana, :source)
|
|
56
|
+
ManaEmptied = define(:mana_emptied, :player, :mana)
|
|
57
|
+
PlayerLost = define(:player_lost, :player, :reason)
|
|
58
|
+
GameEnded = define(:game_ended, :winner, :reason)
|
|
59
|
+
TargetChosen = define(:target_chosen, :player, :object, :target)
|
|
60
|
+
XAnnounced = define(:x_announced, :player, :object, :value)
|
|
61
|
+
ManaPaid = define(:mana_paid, :player, :mana, :object)
|
|
62
|
+
# `during_resolution`: cast while another spell or ability resolves (CR 608.2g), ignoring timing.
|
|
63
|
+
SpellCast = define(:spell_cast, :player, :object, :card, :targets, :during_resolution) do
|
|
64
|
+
def initialize(player:, object:, card:, targets:, during_resolution: false) = super
|
|
65
|
+
end
|
|
66
|
+
AbilityActivated = define(:ability_activated, :player, :object, :card, :source, :ability, :targets)
|
|
67
|
+
CastCancelled = define(:cast_cancelled, :player, :object, :card)
|
|
68
|
+
Resolved = define(:resolved, :object, :card, :kind)
|
|
69
|
+
Fizzled = define(:fizzled, :object, :card)
|
|
70
|
+
# `combat`: dealt as combat damage (CR 510.2); triggers such as "deals combat damage to a player" read it.
|
|
71
|
+
DamageDealt = define(:damage_dealt, :source, :target, :amount, :combat) do
|
|
72
|
+
def initialize(source:, target:, amount:, combat: false) = super
|
|
73
|
+
end
|
|
74
|
+
# A life total changed. CR 119.9: a gain is a life gain event, a loss a life loss event.
|
|
75
|
+
LifeChanged = define(:life_changed, :player, :from, :to) do
|
|
76
|
+
def gain = [to - from, 0].max
|
|
77
|
+
def loss = [from - to, 0].max
|
|
78
|
+
end
|
|
79
|
+
CountersChanged = define(:counters_changed, :object, :counter, :from, :to)
|
|
80
|
+
PlayerCountersChanged = define(:player_counters_changed, :player, :counter, :from, :to) # poison, rad, ... (CR 122.1)
|
|
81
|
+
EmblemCreated = define(:emblem_created, :player, :object, :card) # CR 114.2
|
|
82
|
+
# CR 705: a coin flip; `won` is nil for a flip no one wins or loses (CR 705.2).
|
|
83
|
+
CoinFlipped = define(:coin_flipped, :player, :heads, :won)
|
|
84
|
+
# CR 706.2: a die with `sides` sides rolled by an effect, and its natural result.
|
|
85
|
+
RolledDie = define(:rolled_die, :player, :sides, :result)
|
|
86
|
+
# CR 707.10: a copy of a spell put on the stack (not cast).
|
|
87
|
+
SpellCopied = define(:spell_copied, :player, :object, :card, :original, :targets)
|
|
88
|
+
EffectStarted = define(:effect_started, :kind, :affected, :details)
|
|
89
|
+
EffectsEnded = define(:effects_ended, :count)
|
|
90
|
+
AttackerDeclared = define(:attacker_declared, :object, :target)
|
|
91
|
+
BlockerDeclared = define(:blocker_declared, :object, :attacker)
|
|
92
|
+
DamageAssigned = define(:damage_assigned, :attacker, :blocker)
|
|
93
|
+
# `trigger`: the id of the pending triggered ability ("copy that ability", see Engine#stack_object_for).
|
|
94
|
+
AbilityTriggered = define(:ability_triggered, :player, :source, :card, :ability, :trigger) do
|
|
95
|
+
def initialize(player:, source:, card:, ability:, trigger: nil) = super
|
|
96
|
+
end
|
|
97
|
+
TriggerPutOnStack = define(:trigger_put_on_stack, :player, :object, :source, :card, :ability, :targets)
|
|
98
|
+
TriggerRemoved = define(:trigger_removed, :player, :source, :card, :reason)
|
|
99
|
+
TokenCeased = define(:token_ceased, :object, :card, :zone)
|
|
100
|
+
LibraryReordered = define(:library_reordered, :player, :top, :bottom)
|
|
101
|
+
Attached = define(:attached, :object, :to) # CR 701.3
|
|
102
|
+
Unattached = define(:unattached, :object, :from) # CR 701.3d
|
|
103
|
+
# A card in a hidden zone is shown to every player (CR 701.20a); it doesn't leave its zone (701.20b).
|
|
104
|
+
CardRevealed = define(:card_revealed, :player, :object, :card)
|
|
105
|
+
# A player looks at cards in a hidden zone (CR 701.20e): only they (and the cards' owner) see which.
|
|
106
|
+
LookedAt = define(:looked_at, :player, :owner, :objects, :cards) do
|
|
107
|
+
def for_viewer(viewer)
|
|
108
|
+
return to_h if viewer == :observer || viewer == player || viewer == owner
|
|
109
|
+
|
|
110
|
+
{ "type" => type, "player" => player, "owner" => owner, "count" => objects.size }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
ControlChanged = define(:control_changed, :object, :controller)
|
|
114
|
+
ExtraTurnAdded = define(:extra_turn_added, :player) # CR 500.7
|
|
115
|
+
TurnSkipped = define(:turn_skipped, :player) # CR 614.10
|
|
116
|
+
DilemmaFaced = define(:dilemma_faced, :player, :object, :modes) # CR 700.17
|
|
117
|
+
# Combat declarations once final (CR 508.3d "whenever [a player] attacks", 509.3c "becomes blocked").
|
|
118
|
+
AttacksDeclared = define(:attacks_declared, :player, :attackers)
|
|
119
|
+
BecameBlocked = define(:became_blocked, :object, :blockers)
|
|
120
|
+
# Crew (CR 702.122): a creature tapped to pay a crew cost crews that Vehicle (702.122b); the Vehicle
|
|
121
|
+
# becomes crewed as its crew ability resolves (702.122e), by the creatures in `crew`.
|
|
122
|
+
CrewedVehicle = define(:crewed_vehicle, :object, :vehicle)
|
|
123
|
+
# A permanent tapped to pay a `cost` (e.g. "teamwork", CR 702.194a) of the spell `spell`, once the spell is cast.
|
|
124
|
+
TappedForCost = define(:tapped_for_cost, :object, :cost, :spell)
|
|
125
|
+
BecameCrewed = define(:became_crewed, :object, :crew)
|
|
126
|
+
# Saddle (CR 702.171): a creature tapped to pay a saddle cost saddles that Mount (702.171c); the Mount
|
|
127
|
+
# becomes saddled (702.171b), by the creatures in `riders` when its saddle ability did it.
|
|
128
|
+
SaddledMount = define(:saddled_mount, :object, :mount)
|
|
129
|
+
BecameSaddled = define(:became_saddled, :object, :riders)
|
|
130
|
+
# A player's speed changed (CR 702.179); `from` nil: they had no speed.
|
|
131
|
+
SpeedChanged = define(:speed_changed, :player, :from, :to)
|
|
132
|
+
# A player got a designation for the rest of the game, e.g. an enduring story (CR 702.195b).
|
|
133
|
+
DesignationGained = define(:designation_gained, :player, :designation)
|
|
134
|
+
# A permanent's class level became `level` (CR 717.2a).
|
|
135
|
+
ClassLevelChanged = define(:class_level_changed, :object, :level)
|
|
136
|
+
# CR 701.27a: a double-faced permanent turned over; `card` names the face now up (CR 712).
|
|
137
|
+
Transformed = define(:transformed, :object, :card)
|
|
138
|
+
# CR 723.3a-c: a permanent gained (`prepared` true) or lost the prepared designation.
|
|
139
|
+
PreparedChanged = define(:prepared_changed, :object, :prepared)
|
|
140
|
+
# A keyword action that abilities trigger on, once it's complete: "scry" / "surveil" N (CR 701.22, 701.25);
|
|
141
|
+
# earthbend, waterbend, airbend and firebend (CR 701.66b, 701.67c, 701.65b, 702.189b).
|
|
142
|
+
KeywordAction = define(:keyword_action, :player, :action, :amount)
|
|
143
|
+
|
|
144
|
+
# A card moved between zones; old_id stops existing and new_id is born (CR 400.7). `lookers`: the card
|
|
145
|
+
# was exiled face down, and only these players (by name) see which card it is (CR 406.3).
|
|
146
|
+
ZoneChanged = define(:zone_changed, :card, :old_id, :new_id, :owner, :from, :to, :reason, :lookers) do
|
|
147
|
+
def initialize(card:, old_id:, new_id:, owner:, from:, to:, reason:, lookers: nil) = super
|
|
148
|
+
|
|
149
|
+
def for_viewer(viewer)
|
|
150
|
+
if lookers && viewer != :observer && !lookers.include?(viewer)
|
|
151
|
+
{ "type" => type, "owner" => owner, "new_id" => new_id, "from" => from, "to" => to, "reason" => reason,
|
|
152
|
+
"face_down" => true }
|
|
153
|
+
elsif Events.zone_visible?(from, owner, viewer) || Events.zone_visible?(to, owner, viewer)
|
|
154
|
+
to_h
|
|
155
|
+
else
|
|
156
|
+
{ "type" => type, "owner" => owner, "from" => from, "to" => to, "reason" => reason }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# In-progress processes, kept on a serializable stack (state.frames, innermost last).
|
|
5
|
+
#
|
|
6
|
+
# Anything that may need to stop and ask a player something in the middle of the
|
|
7
|
+
# rules (casting, resolving a spell, assigning combat damage, ...) is a frame:
|
|
8
|
+
# plain JSON data plus a registered frame class. Engine#advance! steps the top
|
|
9
|
+
# frame until it is done or waiting for a decision, so a game can be exported at
|
|
10
|
+
# any point, including halfway through resolving an effect list.
|
|
11
|
+
#
|
|
12
|
+
# A frame class implements:
|
|
13
|
+
# .step(engine, frame) -> :done (pop it), :wait (state.pending is set), or :push (stepped child next)
|
|
14
|
+
# .decisions -> decision kinds that may be pending while it is on top
|
|
15
|
+
# .validate(frame, data, errors) -> snapshot consistency checks
|
|
16
|
+
module Frames
|
|
17
|
+
KINDS = {} # rubocop:disable Style/MutableConstant -- extension registry
|
|
18
|
+
|
|
19
|
+
def self.register(type, klass)
|
|
20
|
+
KINDS[type] = klass
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.fetch(type) = KINDS.fetch(type) { raise ArgumentError, "unknown frame type #{type.inspect}" }
|
|
24
|
+
|
|
25
|
+
# Base frame.
|
|
26
|
+
class Base
|
|
27
|
+
def self.decisions = []
|
|
28
|
+
def self.validate(_frame, _data, _errors) = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Casting a spell or activating an ability (CR 601.2, 602.2).
|
|
32
|
+
class Cast < Base
|
|
33
|
+
def self.step(engine, _frame) = engine.continue_casting!
|
|
34
|
+
|
|
35
|
+
def self.decisions
|
|
36
|
+
%w[choose_kicker choose_cost_option choose_mode choose_x choose_target_count choose_target distribute choose_cost pay_cost]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.validate(frame, data, errors)
|
|
40
|
+
errors << "frames: cast object #{frame["object"]} is not on the stack" unless data["stack"].include?(frame["object"])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
register("cast", Cast)
|
|
44
|
+
|
|
45
|
+
# Resolving the top object of the stack, one effect at a time (CR 608).
|
|
46
|
+
class Resolve < Base
|
|
47
|
+
def self.step(engine, frame) = engine.step_resolution!(frame)
|
|
48
|
+
def self.decisions = %w[effect_choice enter_choice]
|
|
49
|
+
|
|
50
|
+
def self.validate(frame, data, errors)
|
|
51
|
+
errors << "frames: resolving object #{frame["object"]} is not on the stack" unless data["stack"].include?(frame["object"])
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
register("resolve", Resolve)
|
|
55
|
+
|
|
56
|
+
# Assigning and dealing combat damage (CR 510).
|
|
57
|
+
class CombatDamage < Base
|
|
58
|
+
def self.step(engine, frame) = engine.step_combat_damage!(frame)
|
|
59
|
+
def self.decisions = %w[assign_damage]
|
|
60
|
+
end
|
|
61
|
+
register("combat_damage", CombatDamage)
|
|
62
|
+
|
|
63
|
+
# Putting pending triggered abilities on the stack (CR 603.3), then giving priority.
|
|
64
|
+
class PutTriggers < Base
|
|
65
|
+
def self.step(engine, frame) = engine.step_put_triggers!(frame)
|
|
66
|
+
def self.decisions = %w[order_triggers]
|
|
67
|
+
end
|
|
68
|
+
register("put_triggers", PutTriggers)
|
|
69
|
+
|
|
70
|
+
# The legend rule's choice (CR 704.5j).
|
|
71
|
+
class LegendRule < Base
|
|
72
|
+
def self.step(engine, frame) = engine.step_legend_rule!(frame)
|
|
73
|
+
def self.decisions = %w[legend_rule]
|
|
74
|
+
end
|
|
75
|
+
register("legend_rule", LegendRule)
|
|
76
|
+
|
|
77
|
+
# Playing a land that asks an "as this enters" choice first (CR 614.12).
|
|
78
|
+
class PlayLand < Base
|
|
79
|
+
def self.step(engine, frame) = Actions::PlayLand.step(engine, frame)
|
|
80
|
+
def self.decisions = %w[enter_choice]
|
|
81
|
+
end
|
|
82
|
+
register("play_land", PlayLand)
|
|
83
|
+
end
|
|
84
|
+
end
|