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
data/lib/enjine/fuzz.rb
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# Plays random games choosing uniformly among legal options and checks the
|
|
5
|
+
# engine's invariants after every decision. A crash, a stall, a broken invariant
|
|
6
|
+
# or an export/import mismatch raises Fuzz::Failure with the seed to reproduce it.
|
|
7
|
+
module Fuzz
|
|
8
|
+
# Raised when a fuzzed game breaks; carries the reproduction data. `kind`: :finding (the rules monitor
|
|
9
|
+
# disagreed), :stall (the decision cap was hit), :error (an exception, a broken invariant or roundtrip).
|
|
10
|
+
class Failure < Error
|
|
11
|
+
attr_reader :seed, :decisions, :kind
|
|
12
|
+
|
|
13
|
+
def initialize(message, seed:, decisions:, kind: :error)
|
|
14
|
+
@seed = seed
|
|
15
|
+
@decisions = decisions
|
|
16
|
+
@kind = kind
|
|
17
|
+
super("#{message} (seed #{seed}, after #{decisions} decisions)")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Result = Data.define(:games, :decisions, :turns, :outcomes, :casts, :seconds)
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# A random legal deck of `size` cards drawn uniformly from `cards` (card data blocks), whatever their colors,
|
|
26
|
+
# plus `outside` cards its player owns outside the game (CR 400.11a; the copy limit counts both, 100.4a).
|
|
27
|
+
def random_deck(cards, rng:, size: Deck::MIN_SIZE, outside: 0)
|
|
28
|
+
definitions = cards.map { CardDefinition.from_h(it) }
|
|
29
|
+
counts = Hash.new(0)
|
|
30
|
+
extra = Hash.new(0)
|
|
31
|
+
draw(definitions, counts, rng, size) { counts[it] += 1 }
|
|
32
|
+
draw(definitions, counts, rng, outside) do |id|
|
|
33
|
+
counts[id] += 1
|
|
34
|
+
extra[id] += 1
|
|
35
|
+
end
|
|
36
|
+
doc = { "cards" => definitions.select { counts.key?(it.id) }.map(&:to_h),
|
|
37
|
+
"main" => counts.filter_map { |id, n| { "card" => id, "count" => n - extra[id] } if n > extra[id] } }
|
|
38
|
+
doc["outside"] = extra.map { |id, n| { "card" => id, "count" => n } } unless extra.empty?
|
|
39
|
+
doc
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Picks `number` random cards that still fit the four-copy rule, yielding each one's id.
|
|
43
|
+
def draw(definitions, counts, rng, number)
|
|
44
|
+
number.times do
|
|
45
|
+
defn = definitions[rng.rand(definitions.size)]
|
|
46
|
+
defn = definitions[rng.rand(definitions.size)] until defn.deck_limit.nil? || counts[defn.id] < defn.deck_limit
|
|
47
|
+
yield defn.id
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# roundtrip_every: export+import and compare every N decisions (0 disables).
|
|
52
|
+
def run(setup, games:, seed: 0, max_decisions: 20_000, roundtrip_every: 0, invariants: true, monitor: true)
|
|
53
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
54
|
+
totals = { decisions: 0, turns: 0, outcomes: Hash.new(0), casts: Hash.new(0) }
|
|
55
|
+
games.times do |i|
|
|
56
|
+
game_seed = seed + i
|
|
57
|
+
game = setup.new_game(seed: game_seed)
|
|
58
|
+
game.subscribe { |e| totals[:casts][e.card] += 1 if e.is_a?(Events::SpellCast) || e.is_a?(Events::AbilityActivated) }
|
|
59
|
+
watcher = monitor ? RulesMonitor.new(game) : nil
|
|
60
|
+
decisions = play(game, Agents::Random.new(seed: game_seed), game_seed, max_decisions, roundtrip_every,
|
|
61
|
+
invariants, watcher)
|
|
62
|
+
totals[:decisions] += decisions
|
|
63
|
+
totals[:turns] += game.turn
|
|
64
|
+
totals[:outcomes][game.result["reason"]] += 1
|
|
65
|
+
end
|
|
66
|
+
Result.new(games: games, decisions: totals[:decisions], turns: totals[:turns],
|
|
67
|
+
outcomes: totals[:outcomes].to_h,
|
|
68
|
+
casts: totals[:casts].sort_by do |card, n|
|
|
69
|
+
[-n, card]
|
|
70
|
+
end.to_h, seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def play(game, agent, seed, max_decisions, roundtrip_every, invariants, watcher = nil)
|
|
74
|
+
count = 0
|
|
75
|
+
until game.over?
|
|
76
|
+
raise Failure.new("game exceeded #{max_decisions} decisions", seed: seed, decisions: count, kind: :stall) if count >= max_decisions
|
|
77
|
+
|
|
78
|
+
begin
|
|
79
|
+
entry = game.submit(agent.choose(game.pending_decision))
|
|
80
|
+
rescue Failure
|
|
81
|
+
raise
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
raise Failure.new("#{e.class}: #{e.message}", seed: seed, decisions: count)
|
|
84
|
+
end
|
|
85
|
+
count += 1
|
|
86
|
+
check!(game, seed, count) if invariants
|
|
87
|
+
watch!(watcher, entry, seed, count) if watcher
|
|
88
|
+
roundtrip!(game, seed, count) if roundtrip_every.positive? && (count % roundtrip_every).zero?
|
|
89
|
+
end
|
|
90
|
+
count
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def watch!(watcher, entry, seed, count)
|
|
94
|
+
found = watcher.check!(entry)
|
|
95
|
+
return if found.empty?
|
|
96
|
+
|
|
97
|
+
first = found.first
|
|
98
|
+
raise Failure.new("rules finding CR #{first.rule}: #{first.message}", seed: seed, decisions: count, kind: :finding)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def check!(game, seed, count)
|
|
102
|
+
errors = Snapshot.validate(game.state.to_h, game.catalog)
|
|
103
|
+
raise Failure.new("invariant broken: #{errors.join("; ")}", seed: seed, decisions: count) unless errors.empty?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def roundtrip!(game, seed, count)
|
|
107
|
+
copy = Game.import(game.export, catalog: game.catalog)
|
|
108
|
+
same = copy.state.to_h == game.state.to_h && copy.pending_decision&.options == game.pending_decision&.options
|
|
109
|
+
raise Failure.new("export/import roundtrip changed the game", seed: seed, decisions: count) unless same
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/enjine/game.rb
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Enjine
|
|
6
|
+
# The public face of one game.
|
|
7
|
+
#
|
|
8
|
+
# decision = game.pending_decision # => Decision(player:, kind:, options:)
|
|
9
|
+
# game.submit(decision.options.first)
|
|
10
|
+
#
|
|
11
|
+
# An illegal submission raises and never changes the game; `apply` and `import` are all or nothing.
|
|
12
|
+
class Game
|
|
13
|
+
# The decision the engine is waiting for.
|
|
14
|
+
Decision = Data.define(:player, :kind, :options, :details) do
|
|
15
|
+
def to_h
|
|
16
|
+
{ "player" => player, "kind" => kind, "options" => options.map { it.transform_keys(&:to_s) } }
|
|
17
|
+
.merge(details)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :catalog, :history
|
|
22
|
+
|
|
23
|
+
def self.start(catalog:, names:, decklists:, seed:, outside: nil)
|
|
24
|
+
new(catalog, State.initial(names: names, decklists: decklists, seed: seed, outside: outside)).tap(&:start!)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Resumes a game from #export output (a JSON String or the parsed Hash).
|
|
28
|
+
def self.import(snapshot, catalog:)
|
|
29
|
+
data = snapshot.is_a?(String) ? parse_json(snapshot) : Snapshot.deep_stringify(snapshot)
|
|
30
|
+
raise InvalidSnapshot, ["snapshot must contain \"state\""] unless data.is_a?(Hash) && data["state"]
|
|
31
|
+
|
|
32
|
+
state, history = load_parts(data, catalog)
|
|
33
|
+
game = new(catalog, state, history)
|
|
34
|
+
verify_pending!(game) # engine errors here are bugs and propagate unchanged
|
|
35
|
+
game
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.load_parts(data, catalog)
|
|
39
|
+
[Snapshot.load(data["state"], catalog), Array(data["history"]).map { HistoryEntry.from_h(it) }]
|
|
40
|
+
rescue ArgumentError, KeyError, NoMethodError, TypeError => e
|
|
41
|
+
raise InvalidSnapshot, ["malformed snapshot: #{e.message}"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.parse_json(text)
|
|
45
|
+
JSON.parse(text)
|
|
46
|
+
rescue JSON::ParserError => e
|
|
47
|
+
raise InvalidSnapshot, ["not valid JSON: #{e.message}"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.verify_pending!(game)
|
|
51
|
+
return if game.over? || !game.pending_decision.options.empty?
|
|
52
|
+
|
|
53
|
+
raise InvalidSnapshot, ["pending #{game.state.pending["kind"]} decision has no legal options"]
|
|
54
|
+
end
|
|
55
|
+
private_class_method :parse_json, :verify_pending!, :load_parts
|
|
56
|
+
|
|
57
|
+
def initialize(catalog, state, history = [])
|
|
58
|
+
@version = 0
|
|
59
|
+
@catalog = catalog
|
|
60
|
+
@history = history
|
|
61
|
+
@subscribers = []
|
|
62
|
+
@buffer = []
|
|
63
|
+
@engine = Engine.new(catalog, state) { |event| @buffer << event }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @api private
|
|
67
|
+
def start!
|
|
68
|
+
record(nil, nil) { @engine.start! }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def state = @engine.state
|
|
72
|
+
def names = @engine.names
|
|
73
|
+
def over? = state.over?
|
|
74
|
+
def result = state.result&.dup
|
|
75
|
+
def turn = state.turn
|
|
76
|
+
def step = state.step
|
|
77
|
+
|
|
78
|
+
def pending_decision
|
|
79
|
+
pending = state.pending
|
|
80
|
+
return nil unless pending
|
|
81
|
+
|
|
82
|
+
@pending_decision = nil if @pending_for != @version
|
|
83
|
+
@pending_for = @version
|
|
84
|
+
@pending_decision ||= Decision.new(
|
|
85
|
+
player: @engine.name(pending["player"]), kind: pending["kind"], options: @engine.options.freeze,
|
|
86
|
+
details: pending.except("kind", "player")
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def legal_options = pending_decision&.options || []
|
|
91
|
+
|
|
92
|
+
# Submits one option for the pending decision. Returns the HistoryEntry it produced.
|
|
93
|
+
def submit(option)
|
|
94
|
+
decision = pending_decision
|
|
95
|
+
raise IllegalDecision, "the game is over" unless decision
|
|
96
|
+
|
|
97
|
+
chosen = normalize(option)
|
|
98
|
+
unless decision.options.include?(chosen)
|
|
99
|
+
raise IllegalDecision.new("#{chosen.inspect} is not a legal option for #{decision.kind}",
|
|
100
|
+
option: chosen, legal_options: decision.options)
|
|
101
|
+
end
|
|
102
|
+
record(decision.player, decision.kind, chosen) { @engine.submit!(chosen) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Fast-forwards through a list of options, all or nothing.
|
|
106
|
+
def apply(options)
|
|
107
|
+
transaction do
|
|
108
|
+
options.each_with_index do |option, index|
|
|
109
|
+
submit(option)
|
|
110
|
+
rescue IllegalDecision => e
|
|
111
|
+
raise IllegalDecision.new(e.message, option: e.option, legal_options: e.legal_options, index: index)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# CR 104.3a: a player may concede at any time.
|
|
118
|
+
def concede(player)
|
|
119
|
+
raise IllegalDecision, "the game is over" if over?
|
|
120
|
+
|
|
121
|
+
index = player_index(player)
|
|
122
|
+
record(names[index], "concede", { type: "concede" }) { @engine.concede!(index) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Starts this game over from its opening, with the same or a new seed.
|
|
126
|
+
def restart(seed: nil)
|
|
127
|
+
fresh = State.initial(names: names, decklists: state.decklists, seed: seed.nil? ? state.seed : seed,
|
|
128
|
+
outside: state.outside_lists)
|
|
129
|
+
@engine.state = fresh
|
|
130
|
+
@history = []
|
|
131
|
+
@buffer.clear
|
|
132
|
+
start!
|
|
133
|
+
self
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def view_for(viewer) = View.build(@engine, viewer_key(viewer))
|
|
137
|
+
def history_for(viewer) = @history.map { it.for_viewer(viewer_key(viewer)) }
|
|
138
|
+
|
|
139
|
+
# Ids of every decision submitted so far: with the seed this replays the game.
|
|
140
|
+
def decision_log = @history.reject { it.option.nil? || it.kind == "concede" }.map { it.option.transform_keys(&:to_s) }
|
|
141
|
+
|
|
142
|
+
def subscribe(&block)
|
|
143
|
+
@subscribers << block
|
|
144
|
+
self
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def to_h = { "state" => state.to_h, "history" => @history.map(&:to_h) }
|
|
148
|
+
|
|
149
|
+
# An independent copy of this game (state and history) for search or lookahead.
|
|
150
|
+
# Subscribers are not copied.
|
|
151
|
+
def fork = Game.new(@catalog, state.fork, @history.dup)
|
|
152
|
+
def export = JSON.generate(to_h)
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def record(player, kind, option = nil)
|
|
157
|
+
@version = @version.to_i + 1
|
|
158
|
+
@buffer.clear
|
|
159
|
+
before = [state.turn, state.step&.to_s]
|
|
160
|
+
yield
|
|
161
|
+
entry = HistoryEntry.new(seq: @history.size + 1, turn: before[0], step: before[1], player: player,
|
|
162
|
+
kind: kind, option: option, events: @buffer.dup.freeze)
|
|
163
|
+
@history << entry
|
|
164
|
+
deliver(entry.events) unless @in_transaction
|
|
165
|
+
entry
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def transaction
|
|
169
|
+
saved = state.fork
|
|
170
|
+
history_size = @history.size
|
|
171
|
+
@in_transaction = true
|
|
172
|
+
yield
|
|
173
|
+
@in_transaction = false
|
|
174
|
+
deliver(@history[history_size..].flat_map(&:events))
|
|
175
|
+
rescue StandardError
|
|
176
|
+
@in_transaction = false
|
|
177
|
+
@engine.state = saved
|
|
178
|
+
@version += 1
|
|
179
|
+
@history = @history.first(history_size)
|
|
180
|
+
raise
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def deliver(events)
|
|
184
|
+
events.each { |event| @subscribers.each { it.call(event) } }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def normalize(option)
|
|
188
|
+
raise IllegalDecision, "an option must be a Hash, got #{option.class}" unless option.is_a?(Hash)
|
|
189
|
+
|
|
190
|
+
option.to_h { |k, v| [k.to_sym, v.is_a?(Symbol) ? v.to_s : v] }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def player_index(player)
|
|
194
|
+
names.index(player.respond_to?(:name) ? player.name : player) or
|
|
195
|
+
raise ArgumentError, "unknown player #{player.inspect}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def viewer_key(viewer)
|
|
199
|
+
return :observer if viewer.to_s == "observer"
|
|
200
|
+
|
|
201
|
+
names[player_index(viewer)]
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# One entry of a game's history: the decision a player made (nil for the game's
|
|
5
|
+
# opening) and every event it caused.
|
|
6
|
+
HistoryEntry = Data.define(:seq, :turn, :step, :player, :kind, :option, :events) do
|
|
7
|
+
def self.from_h(hash)
|
|
8
|
+
h = hash.transform_keys(&:to_s)
|
|
9
|
+
new(seq: h["seq"], turn: h["turn"], step: h["step"], player: h["player"], kind: h["kind"],
|
|
10
|
+
option: h["option"]&.transform_keys(&:to_sym), events: h["events"].map { Events.from_h(it) })
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_h = for_viewer(:observer)
|
|
14
|
+
|
|
15
|
+
# What `viewer` (a player name or :observer) may know about this entry.
|
|
16
|
+
def for_viewer(viewer)
|
|
17
|
+
shown = if option.nil? || viewer == :observer || viewer == player || !Decisions::KINDS.key?(kind)
|
|
18
|
+
option # concessions (and other non-decision entries) are public
|
|
19
|
+
else
|
|
20
|
+
Decisions.fetch(kind).redact(option, false)
|
|
21
|
+
end
|
|
22
|
+
{ "seq" => seq, "turn" => turn, "step" => step, "player" => player, "kind" => kind,
|
|
23
|
+
"option" => shown&.transform_keys(&:to_s), "events" => events.map { it.for_viewer(viewer) } }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/enjine/mana.rb
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# Mana symbols, colors and mana costs (CR 106, 107.4, 202).
|
|
5
|
+
module Mana
|
|
6
|
+
COLORS = %i[W U B R G].freeze
|
|
7
|
+
# Every type of mana that can sit in a mana pool: five colors plus colorless (CR 106.1).
|
|
8
|
+
TYPES = (COLORS + [:C]).freeze
|
|
9
|
+
SYMBOL = /\{([^{}]*)\}/
|
|
10
|
+
|
|
11
|
+
def self.empty_pool
|
|
12
|
+
TYPES.to_h { |t| [t, 0] }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# A parsed mana cost such as "{2}{R}{R}" or "{X}{R}". `x_symbols` counts the {X} (CR 107.3);
|
|
16
|
+
# their value is announced while casting (CR 601.2b) and is 0 everywhere else (CR 107.3g).
|
|
17
|
+
# `hybrid` lists the hybrid symbols ({R/G}), each as the colors that can pay it (CR 107.4e).
|
|
18
|
+
Cost = Data.define(:generic, :colored, :x_symbols, :hybrid) do
|
|
19
|
+
def initialize(generic:, colored:, x_symbols: 0, hybrid: [].freeze) = super
|
|
20
|
+
|
|
21
|
+
def self.parse(text)
|
|
22
|
+
errors = []
|
|
23
|
+
cost = Mana.parse_cost(text, errors)
|
|
24
|
+
raise ArgumentError, errors.join(", ") unless errors.empty?
|
|
25
|
+
|
|
26
|
+
cost
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Mana value (CR 202.3); {X} counts as 0 off the stack (CR 202.3e). A hybrid symbol counts its
|
|
30
|
+
# largest component (CR 202.3f): 1 for {R/G}, 2 for {2/B}.
|
|
31
|
+
def mana_value = generic + colored.values.sum + hybrid.sum { Mana.mono_hybrid?(it) ? 2 : 1 }
|
|
32
|
+
|
|
33
|
+
# The cost with X replaced by `value` (CR 107.3a): each {X} adds that much generic mana.
|
|
34
|
+
def with_x(value) = with(generic: generic + (x_symbols * value), x_symbols: 0)
|
|
35
|
+
|
|
36
|
+
# The colors of the cost's colored and hybrid symbols, in WUBRG order (CR 202.2, 202.2d, 107.4e).
|
|
37
|
+
def colors = COLORS.select { |c| colored.key?(c) || hybrid.any? { it.include?(c) } }
|
|
38
|
+
|
|
39
|
+
# One entry per colored or two-color hybrid symbol: the mana types that can pay it (CR 601.2h). A
|
|
40
|
+
# monocolored hybrid symbol has no single requirement; see #equivalents.
|
|
41
|
+
def requirements = colored.flat_map { |color, n| Array.new(n) { [color] } } + hybrid.reject { Mana.mono_hybrid?(it) }
|
|
42
|
+
|
|
43
|
+
# The costs without monocolored hybrid symbols this cost can be paid as (CR 601.2b, 107.4e): each {2/B}
|
|
44
|
+
# as {B} or as {2}. Just itself when it has none.
|
|
45
|
+
def equivalents
|
|
46
|
+
mono, rest = hybrid.partition { Mana.mono_hybrid?(it) }
|
|
47
|
+
return [self] if mono.empty?
|
|
48
|
+
|
|
49
|
+
[true, false].repeated_permutation(mono.size).map do |as_color|
|
|
50
|
+
paid = mono.zip(as_color).filter_map { |symbol, color| symbol.last if color }
|
|
51
|
+
with(generic: generic + (2 * (mono.size - paid.size)), hybrid: rest,
|
|
52
|
+
colored: colored.merge(paid.tally) { |_, a, b| a + b })
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_s
|
|
57
|
+
parts = ["{X}"] * x_symbols
|
|
58
|
+
parts << "{#{generic}}" if generic.positive? || (colored.empty? && hybrid.empty? && x_symbols.zero?)
|
|
59
|
+
hybrid.each { parts << "{#{it.join("/")}}" }
|
|
60
|
+
colored.each { |c, n| parts.concat(["{#{c}}"] * n) }
|
|
61
|
+
parts.join
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
HYBRID = %r{\A([WUBRG2])/([WUBRG])\z}
|
|
66
|
+
|
|
67
|
+
# A monocolored hybrid symbol such as {2/B} (CR 107.4e); its first component is the generic "2".
|
|
68
|
+
def self.mono_hybrid?(symbol) = symbol.first.to_s == "2"
|
|
69
|
+
|
|
70
|
+
# The sum of several costs (an ability's mana costs, a spell's mana cost plus a kicker cost).
|
|
71
|
+
def self.sum_costs(costs)
|
|
72
|
+
colored = Hash.new(0)
|
|
73
|
+
costs.each { |cost| cost.colored.each { |c, n| colored[c] += n } }
|
|
74
|
+
Cost.new(generic: costs.sum(&:generic), colored: colored.to_h.freeze, x_symbols: costs.sum(&:x_symbols),
|
|
75
|
+
hybrid: costs.flat_map(&:hybrid).freeze)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Parses a mana cost string; pushes problems into errors and returns a Cost (or nil).
|
|
79
|
+
def self.parse_cost(text, errors, path = "mana_cost")
|
|
80
|
+
unless text.is_a?(String)
|
|
81
|
+
errors << "#{path}: must be a string like \"{1}{G}\""
|
|
82
|
+
return nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
rest = text.gsub(SYMBOL, "")
|
|
86
|
+
unless rest.empty?
|
|
87
|
+
errors << "#{path}: unexpected characters in #{text.inspect}"
|
|
88
|
+
return nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
generic = 0
|
|
92
|
+
x = 0
|
|
93
|
+
colored = {}
|
|
94
|
+
hybrid = []
|
|
95
|
+
text.scan(SYMBOL).flatten.each do |sym|
|
|
96
|
+
case sym
|
|
97
|
+
when /\A\d+\z/ then generic += sym.to_i
|
|
98
|
+
when "X" then x += 1
|
|
99
|
+
when "W", "U", "B", "R", "G", "C" then colored[sym.to_sym] = colored.fetch(sym.to_sym, 0) + 1
|
|
100
|
+
when HYBRID then hybrid << hybrid_symbol(sym, errors, path)
|
|
101
|
+
else errors << "#{path}: unsupported mana symbol {#{sym}}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
Cost.new(generic: generic, colored: colored.freeze, x_symbols: x, hybrid: hybrid.compact.freeze)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A hybrid symbol (CR 107.4e), kept in its printed order: two-color such as {R/G} ([:R, :G]) or
|
|
108
|
+
# monocolored such as {2/B} ([:"2", :B]: one black mana or two mana of any type). Phyrexian symbols
|
|
109
|
+
# aren't supported (they are "unsupported mana symbol"s).
|
|
110
|
+
def self.hybrid_symbol(sym, errors, path)
|
|
111
|
+
pair = HYBRID.match(sym).captures.map(&:to_sym)
|
|
112
|
+
return pair.freeze if pair.uniq.size == 2
|
|
113
|
+
|
|
114
|
+
errors << "#{path}: {#{sym}} needs two different colors"
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Pool types as mana symbols: [:G, :G] -> "{G}{G}".
|
|
119
|
+
def self.symbols(types) = types.map { "{#{it}}" }.join
|
|
120
|
+
|
|
121
|
+
# Parses produced mana such as "{G}" or "{C}{C}" into an Array of pool types.
|
|
122
|
+
def self.parse_produce(text, errors, path)
|
|
123
|
+
unless text.is_a?(String) && !text.empty?
|
|
124
|
+
errors << "#{path}: must be a string like \"{G}\""
|
|
125
|
+
return nil
|
|
126
|
+
end
|
|
127
|
+
unless text.gsub(SYMBOL, "").empty?
|
|
128
|
+
errors << "#{path}: unexpected characters in #{text.inspect}"
|
|
129
|
+
return nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
symbols = text.scan(SYMBOL).flatten
|
|
133
|
+
bad = symbols.reject { |s| TYPES.include?(s.to_sym) }
|
|
134
|
+
unless bad.empty?
|
|
135
|
+
errors << "#{path}: unsupported produced mana #{bad.map { "{#{it}}" }.join}"
|
|
136
|
+
return nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
symbols.map(&:to_sym).freeze
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Abilities
|
|
6
|
+
# "If [cause] causes a triggered ability of a permanent you control to trigger, that ability triggers
|
|
7
|
+
# an additional time" (CR 603.2d). A static ability; the engine asks it each time a triggered ability
|
|
8
|
+
# of a permanent triggers (Engine#additional_triggers). Delayed and reflexive triggered abilities are
|
|
9
|
+
# never affected.
|
|
10
|
+
class AdditionalTriggers < Primitive
|
|
11
|
+
CAUSES = %w[you_gain_life].freeze
|
|
12
|
+
|
|
13
|
+
primitive :ability, :additional_triggers,
|
|
14
|
+
description: "While this permanent is on the battlefield, a triggered ability of a permanent its " \
|
|
15
|
+
"controller controls that triggers because of `cause` triggers an additional time. " \
|
|
16
|
+
"`cause`: you_gain_life (its controller gaining life).",
|
|
17
|
+
rules: %w[603.2d 119.9],
|
|
18
|
+
params: { "cause" => CAUSES.join(" | ") },
|
|
19
|
+
example: { "additional_triggers" => { "cause" => "you_gain_life" } }
|
|
20
|
+
|
|
21
|
+
attr_reader :cause
|
|
22
|
+
|
|
23
|
+
def self.build(params, errors, path, **)
|
|
24
|
+
Mechanics.check_keys(params, %w[cause], errors, path)
|
|
25
|
+
cause = Mechanics.fetch_param(params, :cause)
|
|
26
|
+
errors << "#{path}.cause: must be one of #{CAUSES.join(", ")}" unless CAUSES.include?(cause)
|
|
27
|
+
new(cause)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(cause)
|
|
31
|
+
super()
|
|
32
|
+
@cause = cause
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def kind = :additional_triggers
|
|
37
|
+
def mana_ability? = false
|
|
38
|
+
def allowed_types = nil
|
|
39
|
+
def try_effects = []
|
|
40
|
+
def to_s = "triggered abilities of your permanents that trigger from you gaining life trigger an additional time"
|
|
41
|
+
|
|
42
|
+
# `holder`: the permanent with this ability; `source`: the triggering ability's source (a permanent).
|
|
43
|
+
def additional_trigger?(engine, event, holder, source)
|
|
44
|
+
return false unless source["controller"] == holder.controller
|
|
45
|
+
|
|
46
|
+
event.is_a?(Events::LifeChanged) && event.gain.positive? && engine.names.index(event.player) == holder.controller
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
module Effects
|
|
52
|
+
# "For as long as [this] remains tapped, if a triggered ability of that creature triggers, that ability
|
|
53
|
+
# triggers an additional time" (CR 603.2d, 611.2b): a resolved effect on the referenced permanents
|
|
54
|
+
# (locked in, CR 611.2c). It ends for good when its source untaps, leaves the battlefield or phases out
|
|
55
|
+
# (Engine#end_while_source_effects!); delayed and reflexive triggered abilities are never affected.
|
|
56
|
+
class AdditionalTriggersEffect < Effect
|
|
57
|
+
DURATIONS = %w[while_source_tapped].freeze
|
|
58
|
+
|
|
59
|
+
primitive :effect, :additional_triggers,
|
|
60
|
+
description: "For `duration` (while_source_tapped: for as long as the source remains tapped), each " \
|
|
61
|
+
"triggered ability of each referenced permanent triggers an additional time.",
|
|
62
|
+
rules: %w[603.2d 611.2b 611.2c],
|
|
63
|
+
params: { "to" => "selector", "duration" => DURATIONS.join(" | ") },
|
|
64
|
+
example: { "additional_triggers" => { "to" => { "target" => 0 }, "duration" => "while_source_tapped" } }
|
|
65
|
+
|
|
66
|
+
def self.keys = %w[to duration if]
|
|
67
|
+
|
|
68
|
+
def setup(params, errors, path)
|
|
69
|
+
@duration = Mechanics.fetch_param(params, :duration)
|
|
70
|
+
errors << "#{path}.duration: must be one of #{DURATIONS.join(", ")}" unless DURATIONS.include?(@duration)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# CR 611.2b: if the source isn't tapped (or is gone) as this resolves, the duration never starts.
|
|
74
|
+
def apply(engine, ctx)
|
|
75
|
+
source = engine.state.objects[ctx.source]
|
|
76
|
+
return unless source && engine.state.battlefield.include?(source.id) && source.tapped
|
|
77
|
+
|
|
78
|
+
engine.add_continuous_effect!("additional_triggers", ctx, objects(engine, ctx),
|
|
79
|
+
"duration" => @duration, "source" => ctx.source)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def to_s = "its triggered abilities trigger an additional time while the source remains tapped → #{to}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|