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,404 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# An independent rules checker for fuzzing. It watches a game's events and state
|
|
5
|
+
# and re-derives, from the raw data and the rules, what must be true after each
|
|
6
|
+
# decision, without calling the engine's own rules code. Any mismatch is a
|
|
7
|
+
# finding: the engine did something the rules don't allow.
|
|
8
|
+
#
|
|
9
|
+
# monitor = RulesMonitor.new(game)
|
|
10
|
+
# game.submit(option); monitor.check!(entry) # or use Fuzz.run(..., monitor: true)
|
|
11
|
+
# monitor.findings # => [] when everything matched
|
|
12
|
+
# monitor.stats # mechanics observed, e.g. {"counters_added" => 12, ...}
|
|
13
|
+
class RulesMonitor
|
|
14
|
+
UNLOCKABLE = %i[station_symbol max_speed].freeze # abilities whose own abilities apply only once unlocked
|
|
15
|
+
Finding = Data.define(:rule, :message, :turn, :step, :seq)
|
|
16
|
+
|
|
17
|
+
attr_reader :findings, :stats
|
|
18
|
+
|
|
19
|
+
def initialize(game)
|
|
20
|
+
@game = game
|
|
21
|
+
@findings = []
|
|
22
|
+
@stats = Hash.new(0)
|
|
23
|
+
@lands_this_turn = Hash.new(0)
|
|
24
|
+
@loyalty_activations = Hash.new(0)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def state = @game.state
|
|
28
|
+
def catalog = @game.catalog
|
|
29
|
+
|
|
30
|
+
# Checks one history entry (one decision and everything it caused).
|
|
31
|
+
def check!(entry)
|
|
32
|
+
@entry = entry
|
|
33
|
+
entry.events.each { observe(it) }
|
|
34
|
+
check_state!
|
|
35
|
+
@findings
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def finding(rule, message)
|
|
41
|
+
@findings << Finding.new(rule: rule, message: message, turn: state.turn, step: state.step&.to_s, seq: @entry&.seq)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def defn(card) = catalog.fetch(card)
|
|
45
|
+
|
|
46
|
+
# A permanent's printed abilities, the abilities of its unlocked symbols in their place.
|
|
47
|
+
def abilities_of(obj)
|
|
48
|
+
return defn(obj.card).effect_granted(obj.ability).map(&:ability) if obj.kind == "emblem" # CR 114.3
|
|
49
|
+
|
|
50
|
+
defn(obj.card).abilities.reject { UNLOCKABLE.include?(it.kind) } + unlocked(obj).flat_map(&:nested_abilities)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Its station symbols it has enough charge counters for (CR 722.2a), recomputed from the raw counters, and
|
|
54
|
+
# its max speed abilities while its controller's speed is 4 (CR 702.178a, 702.179e), and its class level
|
|
55
|
+
# bars up to its level (CR 717.2a).
|
|
56
|
+
def unlocked(obj)
|
|
57
|
+
charge = obj.counters.fetch("charge", 0)
|
|
58
|
+
fast = state.players[obj.controller].speed == 4
|
|
59
|
+
level = obj.memory.fetch("class_level", 1) # CR 717.2a, 717.2d
|
|
60
|
+
defn(obj.card).abilities.select do |ability|
|
|
61
|
+
case ability.kind
|
|
62
|
+
when :station_symbol then charge >= ability.threshold
|
|
63
|
+
when :max_speed then fast
|
|
64
|
+
else ability.is_a?(Mechanics::Abilities::ClassLevel) && level >= ability.level
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ---- per-event checks -------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def observe(event)
|
|
72
|
+
@stats[event.type] += 1
|
|
73
|
+
case event
|
|
74
|
+
when Events::TurnBegan then turn_began!
|
|
75
|
+
when Events::StepBegan then step_began!(event)
|
|
76
|
+
when Events::ZoneChanged then zone_changed!(event)
|
|
77
|
+
when Events::SpellCast then spell_cast!(event)
|
|
78
|
+
when Events::AbilityActivated then ability_activated!(event)
|
|
79
|
+
when Events::AttackerDeclared then attacker_declared!(event)
|
|
80
|
+
when Events::BlockerDeclared then blocker_declared!(event)
|
|
81
|
+
when Events::CountersChanged then @stats["counters_#{event.counter}"] += event.to - event.from
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def turn_began!
|
|
86
|
+
@lands_this_turn.clear
|
|
87
|
+
@loyalty_activations.clear
|
|
88
|
+
# CR 514.2: after cleanup no damage is marked and no "until end of turn" effect remains.
|
|
89
|
+
state.battlefield.each do |id|
|
|
90
|
+
obj = state.objects[id]
|
|
91
|
+
next unless obj.damage.positive? && !keyword?(obj, :keeps_damage)
|
|
92
|
+
|
|
93
|
+
finding("514.2", "#{id} still has #{obj.damage} damage at the start of a turn")
|
|
94
|
+
end
|
|
95
|
+
return if state.turn == 1 || state.effects.none? { it["duration"] == "end_of_turn" }
|
|
96
|
+
|
|
97
|
+
finding("514.2", "an until-end-of-turn effect survived cleanup")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def step_began!(_event)
|
|
101
|
+
# CR 500.5: mana pools, restricted mana included, empty between steps.
|
|
102
|
+
state.players.each do |pl|
|
|
103
|
+
# CR 702.189a: "until end of combat" mana is still there as each combat step begins
|
|
104
|
+
next if pl.mana_pool.values.all?(&:zero?) && pl.restricted_mana.all? { Engine::ManaSources.mana_held?(it, state.step) }
|
|
105
|
+
|
|
106
|
+
finding("500.5", "#{pl.name}'s mana pool isn't empty at #{state.step}")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def zone_changed!(event)
|
|
111
|
+
land_played!(event) if event.reason == "play_land"
|
|
112
|
+
return unless event.reason == "resolve" && event.to == "graveyard" && defn(event.card).permanent?
|
|
113
|
+
|
|
114
|
+
finding("608.3", "permanent spell #{event.card} went to the graveyard on resolution")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# CR 305.1-305.2: whoever plays the land controls it (an exiled card of an opponent's may be played).
|
|
118
|
+
def land_played!(event)
|
|
119
|
+
land = state.objects[event.new_id]
|
|
120
|
+
player = land ? state.players[land.controller].name : event.owner
|
|
121
|
+
@lands_this_turn[player] += 1
|
|
122
|
+
allowed = 1 + additional_land_plays(state.players.index { it.name == player })
|
|
123
|
+
finding("305.2", "#{player} played #{@lands_this_turn[player]} lands this turn") if @lands_this_turn[player] > allowed
|
|
124
|
+
active = state.players[state.active].name
|
|
125
|
+
finding("305.1", "#{player} played a land on #{active}'s turn") if player != active
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# CR 305.2: "you may play an additional land" abilities of the player's permanents, from the raw card data
|
|
129
|
+
# and from resolved effects that granted them (Effects::Become).
|
|
130
|
+
def additional_land_plays(index)
|
|
131
|
+
state.battlefield.sum do |id|
|
|
132
|
+
obj = state.objects[id]
|
|
133
|
+
next 0 unless obj.controller == index
|
|
134
|
+
|
|
135
|
+
printed = defn(obj.card).abilities.count { it.kind == :additional_land_plays }
|
|
136
|
+
granted = state.effects.select { it["kind"] == "abilities" && it["affected"].include?(id) }.sum do |effect|
|
|
137
|
+
defn(effect["card"]).effect_granted(effect["ref"]).count { it.ability.kind == :additional_land_plays }
|
|
138
|
+
end
|
|
139
|
+
printed + granted
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# CR 307.1 / 301.1 etc.: sorcery-speed spells only in your main phase with an empty stack.
|
|
144
|
+
def spell_cast!(event)
|
|
145
|
+
card = defn(event.card)
|
|
146
|
+
@stats["cast:#{event.card}"] += 1
|
|
147
|
+
return if card.instant_speed? || event.during_resolution # CR 608.2g: timing doesn't apply
|
|
148
|
+
|
|
149
|
+
caster = event.player
|
|
150
|
+
return if sneaked?(event, caster)
|
|
151
|
+
|
|
152
|
+
# The stack was empty as casting began (CR 601.2a) when the spell is at its bottom; what went on it since
|
|
153
|
+
# ("whenever you cast" triggers, even one still waiting for its targets, CR 603.3d) doesn't count.
|
|
154
|
+
unless state.players[state.active].name == caster && %i[precombat_main postcombat_main].include?(state.step) &&
|
|
155
|
+
state.stack.last == event.object
|
|
156
|
+
finding("307.1", "#{caster} cast sorcery-speed #{event.card} at #{state.step} (stack #{state.stack.size})")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# CR 702.190a: a spell cast for its sneak cost, at instant speed during its caster's declare blockers step.
|
|
161
|
+
def sneaked?(event, caster)
|
|
162
|
+
state.objects[event.object]&.memory&.[]("cast_with") == "sneak" && state.step == :declare_blockers &&
|
|
163
|
+
state.players[state.active].name == caster
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Printed keywords plus those of unlocked symbols, keywords granted and those of statics. A permanent that
|
|
167
|
+
# loses all abilities (CR 613.1f) has only the keywords granted to it (timestamps are the engine's specs').
|
|
168
|
+
def keyword?(obj, keyword)
|
|
169
|
+
return granted_keywords(obj.id).include?(keyword.to_sym) if stripped?(obj)
|
|
170
|
+
|
|
171
|
+
defn(obj.card).keyword?(keyword) || unlocked(obj).any? { it.keywords.include?(keyword) } ||
|
|
172
|
+
granted_keywords(obj.id).include?(keyword.to_sym) || static_keyword?(obj, keyword) ||
|
|
173
|
+
(keyword.to_sym == :haste && obj.memory["haste_while_controlled_by"] == obj.controller) # cast with suspend, CR 702.62a
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Whether an effect or an applying static ability makes it lose all abilities (CR 613.1f).
|
|
177
|
+
def stripped?(obj)
|
|
178
|
+
return true if state.effects.any? { it["kind"] == "lose_abilities" && it["affected"].include?(obj.id) }
|
|
179
|
+
|
|
180
|
+
applying_statics(obj).any?(&:loses_abilities?)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Permanents and emblems, whose static abilities function in the command zone (CR 114.4).
|
|
184
|
+
def static_holders = state.battlefield + (state.command || [])
|
|
185
|
+
|
|
186
|
+
# The static abilities of permanents on the battlefield and emblems that apply to `obj` now, matched on printed types.
|
|
187
|
+
def applying_statics(obj)
|
|
188
|
+
engine = @game.instance_variable_get(:@engine)
|
|
189
|
+
facts = facts(obj, defn(obj.card))
|
|
190
|
+
static_holders.flat_map do |id|
|
|
191
|
+
source = state.objects[id]
|
|
192
|
+
abilities_of(source).select do |ability|
|
|
193
|
+
ability.kind == :static && ability.affects?(obj, source, facts) && ability.active?(engine, source)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Keywords granted until end of turn or by an attached Aura or Equipment (CR 301.5f, 303.4m).
|
|
199
|
+
def granted_keywords(id)
|
|
200
|
+
until_eot = state.effects.select { it["kind"] == "grant" && it["affected"].include?(id) }.flat_map { it["keywords"] }
|
|
201
|
+
(until_eot + attached_grants(id).flat_map { it["keywords"] }).map(&:to_sym)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def ability_activated!(event)
|
|
205
|
+
source = defn(event.card)
|
|
206
|
+
ability = source.ability(event.ability)
|
|
207
|
+
return unless ability&.kind == :loyalty
|
|
208
|
+
|
|
209
|
+
@loyalty_activations[event.source] += 1
|
|
210
|
+
if @loyalty_activations[event.source] > 1
|
|
211
|
+
finding("606.3",
|
|
212
|
+
"#{event.source} activated #{@loyalty_activations[event.source]} loyalty abilities this turn")
|
|
213
|
+
end
|
|
214
|
+
changes = @entry.events.grep(Events::CountersChanged).select { it.object == event.source && it.counter == "loyalty" }
|
|
215
|
+
paid = changes.sum { it.to - it.from }
|
|
216
|
+
return unless paid != ability.loyalty_cost && state.objects.key?(event.source) && changes.any?
|
|
217
|
+
|
|
218
|
+
finding("606.4",
|
|
219
|
+
"#{event.source} loyalty changed by #{paid}, cost was #{ability.loyalty_cost}")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# CR 302.6 / 508.1a: attackers were untapped and not summoning sick (or have haste).
|
|
223
|
+
def attacker_declared!(event)
|
|
224
|
+
obj = state.objects[event.object]
|
|
225
|
+
return unless obj
|
|
226
|
+
|
|
227
|
+
active_turn = state.turn
|
|
228
|
+
sick = obj.controlled_since.nil? || obj.controlled_since >= active_turn
|
|
229
|
+
finding("302.6", "summoning-sick #{event.object} attacked") if sick && !keyword?(obj, :haste)
|
|
230
|
+
return unless keyword?(obj, :defender) && !keyword?(obj, :attacks_despite_defender)
|
|
231
|
+
|
|
232
|
+
finding("702.3b", "#{event.object} has defender but attacked")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# A keyword from an applying static ability of a permanent (CR 604.2), matched on printed types.
|
|
236
|
+
def static_keyword?(obj, keyword) = applying_statics(obj).any? { it.keywords.include?(keyword) }
|
|
237
|
+
|
|
238
|
+
# CR 702.9b: a creature with flying can be blocked only by creatures with flying or reach.
|
|
239
|
+
def blocker_declared!(event)
|
|
240
|
+
blocker = state.objects[event.object]
|
|
241
|
+
attacker = state.objects[event.attacker]
|
|
242
|
+
return unless blocker && attacker && keyword?(attacker, :flying)
|
|
243
|
+
return if keyword?(blocker, :flying) || keyword?(blocker, :reach)
|
|
244
|
+
|
|
245
|
+
finding("702.9b", "#{event.object} without flying or reach blocked flying #{event.attacker}")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# ---- state checks after each decision ------------------------------------------
|
|
249
|
+
|
|
250
|
+
def check_state!
|
|
251
|
+
check_power_toughness!
|
|
252
|
+
check_damage_life!
|
|
253
|
+
check_sba! if @game.pending_decision&.kind == "priority"
|
|
254
|
+
check_hand_size!
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# CR 613.4, recomputed independently: printed (or a 7b setting effect) + counters + active pump effects
|
|
258
|
+
# + static ability bonuses. Characteristic-defining power/toughness (7a) is left to the engine's specs.
|
|
259
|
+
def check_power_toughness!
|
|
260
|
+
return if state.battlefield.any? { stripped?(state.objects[it]) } # CR 613.1f, 613.8a: the engine's specs
|
|
261
|
+
|
|
262
|
+
view = @game.view_for(:observer)["battlefield"].to_h { [it["id"], it] }
|
|
263
|
+
statics = static_pumps
|
|
264
|
+
state.battlefield.each do |id|
|
|
265
|
+
obj = state.objects[id]
|
|
266
|
+
card = defn(obj.card)
|
|
267
|
+
next unless recomputable?(obj, card)
|
|
268
|
+
|
|
269
|
+
net = obj.counters.fetch("+1/+1", 0) - obj.counters.fetch("-1/-1", 0)
|
|
270
|
+
pumps = state.effects.select { it["kind"] == "pump" && it["affected"].include?(id) } + attached_grants(id) +
|
|
271
|
+
static_bonuses(obj, card, statics)
|
|
272
|
+
base = base_power_toughness(obj, card)
|
|
273
|
+
expected = [base[0] + net + pumps.sum { it["power"] }, base[1] + net + pumps.sum { it["toughness"] }]
|
|
274
|
+
actual = view.fetch(id).values_at("power", "toughness")
|
|
275
|
+
finding("613.4c", "#{id} is #{actual.join("/")}, expected #{expected.join("/")}") if actual != expected
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# A creature whose power/toughness isn't characteristic-defining (7a) or set by a static ability (7b).
|
|
280
|
+
# One whose creature types an effect replaced (assimilate, "becomes a Borg", layer 4) is left to the engine's
|
|
281
|
+
# specs: which statics apply to it depends on those types.
|
|
282
|
+
def recomputable?(obj, card)
|
|
283
|
+
card.creature? && !card.defined_pt? && applying_statics(obj).none?(&:base_power_toughness) && !types_replaced?(obj)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Creature types an effect replaced (layer 4): the monitor matches statics against printed types.
|
|
287
|
+
def types_replaced?(obj)
|
|
288
|
+
state.effects.any? { %w[creature_types assimilate].include?(it["kind"]) && Array(it["affected"]).include?(obj.id) }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# CR 613.4b: the latest setting effect, else as printed, or as a copy's exceptions or a token's creating
|
|
292
|
+
# effect set them (CR 707.9b, 707.2: "copy_except").
|
|
293
|
+
def base_power_toughness(obj, card)
|
|
294
|
+
setting = state.effects.select { it["kind"] == "base_power_toughness" && it["affected"].include?(obj.id) }.last
|
|
295
|
+
return setting.values_at("power", "toughness") if setting
|
|
296
|
+
|
|
297
|
+
except = obj.memory["copy_effects"]&.last&.[]("except") || obj.memory["copy_except"] || {}
|
|
298
|
+
[except.fetch("power", card.power), except.fetch("toughness", card.toughness)]
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Static abilities printed on permanents that give a bonus to more than the attached permanent and
|
|
302
|
+
# apply now: [[source object, ability, [power, toughness]], ...]. Conditions and amounts are the
|
|
303
|
+
# primitives' own; which creatures get the bonus is decided here from printed types and raw counters.
|
|
304
|
+
def static_pumps
|
|
305
|
+
engine = @game.instance_variable_get(:@engine)
|
|
306
|
+
static_holders.flat_map do |id|
|
|
307
|
+
source = state.objects[id]
|
|
308
|
+
abilities_of(source).filter_map do |ability|
|
|
309
|
+
next unless ability.kind == :static && ability.pump? && !ability.attached? && ability.active?(engine, source)
|
|
310
|
+
|
|
311
|
+
[source, ability, ability.bonus(engine, source)]
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def static_bonuses(obj, card, statics)
|
|
317
|
+
facts = facts(obj, card)
|
|
318
|
+
statics.filter_map do |source, ability, (power, toughness)|
|
|
319
|
+
{ "power" => power, "toughness" => toughness } if ability.affects?(obj, source, facts)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# What statics match a permanent on: its printed characteristics, every creature type for a changeling
|
|
324
|
+
# (CR 702.73a) and the keywords it was granted (a bonus to creatures "with flying", CR 702.1).
|
|
325
|
+
def facts(obj, card)
|
|
326
|
+
chars = Mechanics::Keywords::Changeling.apply(card.printed_characteristics)
|
|
327
|
+
chars = chars.with(keywords: chars.keywords | granted_keywords(obj.id))
|
|
328
|
+
# A crewed Vehicle is an artifact creature (CR 702.122b): the types an effect adds count.
|
|
329
|
+
added = state.effects.select { it["kind"] == "add_types" && it["affected"].include?(obj.id) }.flat_map { it["types"] }
|
|
330
|
+
chars = chars.with(types: chars.types | added.map(&:to_sym)) unless added.empty?
|
|
331
|
+
Mechanics::Filters::Facts.new(chars: chars, counters: obj.counters, token: card.token?)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# "Equipped/enchanted creature gets/has ..." of every permanent attached to `id` whose condition holds
|
|
335
|
+
# ("during your turn", CR 604.2): [{"power" => n, "toughness" => n, "keywords" => [...]}].
|
|
336
|
+
def attached_grants(id)
|
|
337
|
+
engine = @game.instance_variable_get(:@engine)
|
|
338
|
+
state.battlefield.filter_map { state.objects[it] if state.objects[it].attached_to == id }.flat_map do |att|
|
|
339
|
+
abilities_of(att).filter_map do |ability|
|
|
340
|
+
next unless ability.kind == :static && ability.attached? && ability.active?(engine, att)
|
|
341
|
+
|
|
342
|
+
power, toughness = ability.bonus(engine, att)
|
|
343
|
+
{ "power" => power, "toughness" => toughness, "keywords" => ability.keywords }
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# CR 120.3a: damage to a player causes that much life loss.
|
|
349
|
+
def check_damage_life!
|
|
350
|
+
events = @entry.events
|
|
351
|
+
events.grep(Events::DamageDealt).each do |dmg|
|
|
352
|
+
next unless dmg.target.start_with?("player:")
|
|
353
|
+
|
|
354
|
+
@stats["damage_to_players"] += dmg.amount
|
|
355
|
+
name = dmg.target.delete_prefix("player:")
|
|
356
|
+
losses = events.grep(Events::LifeChanged).select { it.player == name && it.to < it.from }.sum { it.from - it.to }
|
|
357
|
+
finding("120.3a", "#{name} took #{dmg.amount} damage but lost #{losses} life") if losses < dmg.amount
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# CR 704.5f/g/i: no creature with lethal damage or 0 toughness, no planeswalker with 0 loyalty, when a player has priority.
|
|
362
|
+
def check_sba!
|
|
363
|
+
view = @game.view_for(:observer)["battlefield"]
|
|
364
|
+
view.each do |perm|
|
|
365
|
+
lethal = perm.key?("toughness") && perm["damage"].positive? && perm["damage"] >= perm["toughness"] &&
|
|
366
|
+
!indestructible?(state.objects[perm["id"]]) # CR 702.12b: lethal damage doesn't destroy it
|
|
367
|
+
if perm.key?("toughness") && (perm["toughness"] <= 0 || lethal)
|
|
368
|
+
finding("704.5g", "#{perm["id"]} survived with #{perm["damage"]} damage and toughness #{perm["toughness"]}")
|
|
369
|
+
end
|
|
370
|
+
finding("704.5i", "#{perm["id"]} survived with 0 loyalty") if perm.key?("loyalty") && perm["loyalty"] <= 0
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# With replaced creature types, a static keyed on them may grant it.
|
|
375
|
+
def indestructible?(obj) = keyword?(obj, :indestructible) || types_replaced?(obj)
|
|
376
|
+
|
|
377
|
+
# CR 514.1: after cleanup the active player has at most seven cards (checked at the next turn's priority).
|
|
378
|
+
# The player whose turn ended is the one active before (an extra turn may follow its own, CR 500.7).
|
|
379
|
+
def check_hand_size!
|
|
380
|
+
previous_name = @active_name
|
|
381
|
+
@active_name = state.players[state.active].name if state.active
|
|
382
|
+
return unless previous_name && @entry.events.any?(Events::TurnBegan)
|
|
383
|
+
|
|
384
|
+
previous = state.players.find { it.name == previous_name }
|
|
385
|
+
return if no_max_hand_size?(previous_name)
|
|
386
|
+
|
|
387
|
+
finding("514.1", "#{previous.name} ended their turn with #{previous.hand.size} cards") if previous.hand.size > 7
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# CR 402.2: an effect or a permanent's ability may give a player no maximum hand size.
|
|
391
|
+
def no_max_hand_size?(name)
|
|
392
|
+
ref = "player:#{name}"
|
|
393
|
+
return true if state.effects.any? { it["rule"] == "no_max_hand_size" && it["affected"].include?(ref) }
|
|
394
|
+
|
|
395
|
+
index = state.players.index { it.name == name }
|
|
396
|
+
state.battlefield.any? do |id|
|
|
397
|
+
obj = state.objects[id]
|
|
398
|
+
defn(obj.card).abilities.any? do |ability|
|
|
399
|
+
ability.kind == :player_rule && ability.rule == "no_max_hand_size" && ability.applies_to?(index, obj.controller)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
data/lib/enjine/setup.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# A frozen, compiled pairing of two ready players. Compile once, spawn many games:
|
|
5
|
+
#
|
|
6
|
+
# setup = Enjine::Setup.new(alice, bob)
|
|
7
|
+
# game = setup.new_game(seed: 42)
|
|
8
|
+
#
|
|
9
|
+
# The catalog and decklists are captured at compile time and shared by every game,
|
|
10
|
+
# so later deck edits never affect an existing Setup.
|
|
11
|
+
class Setup
|
|
12
|
+
attr_reader :players, :catalog, :decklists, :outside
|
|
13
|
+
|
|
14
|
+
def initialize(*players)
|
|
15
|
+
players = players.flatten
|
|
16
|
+
raise ArgumentError, "a game needs exactly 2 players" unless players.size == 2
|
|
17
|
+
raise ArgumentError, "player names must differ" if players.map(&:name).uniq.size != 2
|
|
18
|
+
|
|
19
|
+
players.each { |p| raise PlayerNotReady.new(p.deck.errors, name: p.name) unless p.ready? }
|
|
20
|
+
|
|
21
|
+
@players = players.freeze
|
|
22
|
+
@catalog = Catalog.new
|
|
23
|
+
players.each { |p| @catalog.merge(p.deck.catalog) }
|
|
24
|
+
@catalog.freeze
|
|
25
|
+
@decklists = players.map { |p| p.deck.card_ids.freeze }.freeze
|
|
26
|
+
@outside = players.map { |p| p.deck.outside_ids.freeze }.freeze # CR 400.11a: the cards they own outside the game
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def names = @players.map(&:name)
|
|
31
|
+
|
|
32
|
+
def new_game(seed:)
|
|
33
|
+
Game.start(catalog: @catalog, names: names, decklists: @decklists, seed: seed, outside: @outside).tap do |game|
|
|
34
|
+
@players.each { |p| p.seat(game) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Resumes a game from a snapshot produced by Game#export.
|
|
39
|
+
def import(snapshot)
|
|
40
|
+
Game.import(snapshot, catalog: @catalog).tap do |game|
|
|
41
|
+
@players.each { |p| p.seat(game) if game.names.include?(p.name) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# Snapshot validation of object ids and of player-level lists (cards outside the game, restricted mana).
|
|
5
|
+
module Snapshot
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# The id names its card ("card#n"): its own card for a copy (Engine::Copies), none if face down (CR 406.3),
|
|
9
|
+
# the card (not the face up) for a card with faces ("alfa/1" is "alfa#n", CardDefinition::Faces).
|
|
10
|
+
def validate_object_id(obj, path, catalog, next_id, errors)
|
|
11
|
+
id = obj["id"]
|
|
12
|
+
match = /\A([a-z0-9_]+)#(\d+)\z/.match(id.to_s)
|
|
13
|
+
own = (obj["memory"].is_a?(Hash) && obj["memory"]["own_card"]) || obj["card"]
|
|
14
|
+
face_down = match && match[1] == Engine::FACE_DOWN_ID && obj["zone"].to_s == "exile" && obj.dig("memory", "face_down") == true
|
|
15
|
+
named = own.to_s.split(CardDefinition::FACE_SEPARATOR).first
|
|
16
|
+
errors << "#{path}.id: malformed #{id.inspect}" unless match && (match[1] == named || face_down)
|
|
17
|
+
errors << "#{path}.memory.own_card: #{own.inspect} is not in the catalog" unless catalog.include?(own.to_s)
|
|
18
|
+
errors << "#{path}.id: #{id} is not below next_id #{next_id}" if match && match[2].to_i >= next_id.to_i
|
|
19
|
+
errors << "#{path}.card: #{obj["card"].inspect} is not in the catalog" unless catalog.include?(obj["card"].to_s)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Cards outside the game (CR 400.11a): definition ids only (they aren't objects, CR 400.11), each in the
|
|
23
|
+
# catalog; absent in older snapshots. `outside_lists` holds what each player started with.
|
|
24
|
+
def validate_outside(data, catalog, errors)
|
|
25
|
+
check = lambda do |list, path|
|
|
26
|
+
next errors << "#{path}: must be a list of card ids" unless list.is_a?(Array)
|
|
27
|
+
|
|
28
|
+
list.each_with_index do |id, j|
|
|
29
|
+
errors << "#{path}[#{j}]: #{id.inspect} is not in the catalog" unless id.is_a?(String) && catalog[id]&.id == id
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
lists = data.fetch("outside_lists", [[], []])
|
|
33
|
+
shaped = lists.is_a?(Array) && lists.size == 2
|
|
34
|
+
errors << "outside_lists: must be a list of 2 card id lists" unless shaped
|
|
35
|
+
lists.each_with_index { |list, i| check.call(list, "outside_lists[#{i}]") } if shaped
|
|
36
|
+
Array(data["players"]).each_with_index do |pl, i|
|
|
37
|
+
check.call(pl.fetch("outside", []), "players[#{i}].outside") if pl.is_a?(Hash)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Pool mana kept apart: restricted (CR 106.6) and/or kept until end of combat (CR 702.189a):
|
|
42
|
+
# [{"mana" => "C", "spend_only" => {...}, "until" => "end_of_combat"}]; absent in older snapshots.
|
|
43
|
+
def validate_restricted_mana(list, errors, path)
|
|
44
|
+
return errors << "#{path}: must be a list" unless list.is_a?(Array)
|
|
45
|
+
|
|
46
|
+
list.each_with_index do |entry, i|
|
|
47
|
+
ok = entry.is_a?(Hash) && (entry.keys - %w[mana spend_only until from]).empty? && Mana::TYPES.include?(entry["mana"].to_s.to_sym) &&
|
|
48
|
+
[nil, "end_of_combat"].include?(entry["until"]) && (entry.key?("spend_only") || entry.key?("until")) &&
|
|
49
|
+
(!entry.key?("from") || valid_mana_source?(entry["from"]))
|
|
50
|
+
problems = []
|
|
51
|
+
Mechanics::SpendOnly.build(entry["spend_only"], problems, "#{path}[#{i}].spend_only") if ok && entry.key?("spend_only")
|
|
52
|
+
unless ok
|
|
53
|
+
errors << "#{path}[#{i}]: must be {\"mana\": W/U/B/R/G/C, \"spend_only\": restriction, \"until\": \"end_of_combat\", " \
|
|
54
|
+
"\"from\": source}"
|
|
55
|
+
end
|
|
56
|
+
errors.concat(problems)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The remembered sources of pool mana: one per mana at most (Engine::ManaSources#add_mana!).
|
|
61
|
+
def validate_pool_sources(player, errors, path)
|
|
62
|
+
list = player.fetch("pool_sources", [])
|
|
63
|
+
pool = player["mana_pool"].is_a?(Hash) ? player["mana_pool"] : {}
|
|
64
|
+
ok = list.is_a?(Array) && list.all? do |entry|
|
|
65
|
+
entry.is_a?(Hash) && entry.keys.sort == %w[from mana] && Mana::TYPES.include?(entry["mana"].to_s.to_sym) &&
|
|
66
|
+
valid_mana_source?(entry["from"])
|
|
67
|
+
end
|
|
68
|
+
ok &&= list.map { it["mana"] }.tally.all? { |type, n| n <= pool.fetch(type, 0).to_i }
|
|
69
|
+
errors << "#{path}: must list {\"mana\": type, \"from\": source}, at most one per mana in the pool" unless ok
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A mana source's remembered characteristics (Engine::ManaSources#mana_provenance).
|
|
73
|
+
def valid_mana_source?(from)
|
|
74
|
+
from.is_a?(Hash) && from.keys.sort == %w[colors name subtypes supertypes types] && from["name"].is_a?(String) &&
|
|
75
|
+
%w[colors subtypes supertypes types].all? { from[it].is_a?(Array) && from[it].all?(String) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Player designations (Engine::Designations): distinct known names; absent in older snapshots.
|
|
79
|
+
def validate_designations(list, errors, path)
|
|
80
|
+
known = Engine::Designations::NAMES
|
|
81
|
+
return if list.is_a?(Array) && list.uniq.size == list.size && list.all? { known.include?(it) }
|
|
82
|
+
|
|
83
|
+
errors << "#{path}: must be a list of distinct designations (#{known.join(", ")})"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# Snapshot validation of the turn, the pending decision, delayed triggers and extra turns.
|
|
5
|
+
module Snapshot
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# CR 603.7: each delayed triggered ability names a card in the catalog and one of its delayed abilities.
|
|
9
|
+
def validate_delayed_triggers(data, catalog, errors)
|
|
10
|
+
list = data.fetch("delayed_triggers", [])
|
|
11
|
+
return errors << "delayed_triggers: must be a list" unless list.is_a?(Array)
|
|
12
|
+
|
|
13
|
+
list.each_with_index do |entry, i|
|
|
14
|
+
path = "delayed_triggers[#{i}]"
|
|
15
|
+
next errors << "#{path}: must be an object" unless entry.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
errors << "#{path}.controller: must be 0 or 1" unless [0, 1].include?(entry["controller"])
|
|
18
|
+
errors << "#{path}.results: must be a list" unless entry.fetch("results", []).is_a?(Array)
|
|
19
|
+
card = catalog.include?(entry["card"].to_s) && catalog.fetch(entry["card"])
|
|
20
|
+
next if card && card.ability(entry["ability"].to_s).respond_to?(:trigger)
|
|
21
|
+
|
|
22
|
+
errors << "#{path}: #{entry["card"].inspect} has no delayed ability #{entry["ability"].inspect}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# CR 500.7: extra turns still to be taken (the last one first) and the players who began one.
|
|
27
|
+
def validate_extra_turns(data, errors)
|
|
28
|
+
extra = data.fetch("extra_turns", { "queue" => [], "begun" => [] })
|
|
29
|
+
ok = extra.is_a?(Hash) && (extra.keys - ["skip"]).sort == %w[begun queue] && # "skip": turns to skip (CR 614.10)
|
|
30
|
+
extra.values.all? { |list| list.is_a?(Array) && list.all? { [0, 1].include?(it) } }
|
|
31
|
+
errors << "extra_turns: must be {\"queue\": [player index...], \"begun\": [...], \"skip\": [...] (optional)}" unless ok
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# CR 500.8: phases added this turn (State.no_extra_phases).
|
|
35
|
+
def validate_extra_phases(data, errors)
|
|
36
|
+
extra = data.fetch("extra_phases", State.no_extra_phases)
|
|
37
|
+
ok = extra.is_a?(Hash) && (extra.keys - %w[pending queue resume combats attackers]).empty? && added_phases?(extra["queue"]) &&
|
|
38
|
+
(extra["attackers"].nil? || attack_filter?(extra["attackers"])) &&
|
|
39
|
+
extra["combats"].is_a?(Integer) && !extra["combats"].negative? &&
|
|
40
|
+
(extra["resume"].nil? || Steps::BY_NAME.key?(extra["resume"].to_s.to_sym)) &&
|
|
41
|
+
extra["pending"].is_a?(Array) && extra["pending"].all? { pending_phases?(it) }
|
|
42
|
+
return if ok
|
|
43
|
+
|
|
44
|
+
errors << "extra_phases: must be {\"pending\": [{\"after\": phase, \"phases\": [...]}], \"queue\": " \
|
|
45
|
+
"[combat|main|{\"phase\": \"combat\", \"attackers\": filter}...], \"resume\": step, \"combats\": n, " \
|
|
46
|
+
"\"attackers\": filter (optional)}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# An added phase: "combat" | "main", or a combat phase only some creatures can attack in (CR 508.1c).
|
|
50
|
+
def added_phases?(list)
|
|
51
|
+
list.is_a?(Array) && list.all? do |phase|
|
|
52
|
+
Engine::ADDED_PHASES.key?(phase) ||
|
|
53
|
+
(phase.is_a?(Hash) && phase.keys.sort == %w[attackers phase] && phase["phase"] == "combat" && attack_filter?(phase["attackers"]))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def attack_filter?(data) = [].tap { Mechanics.build_entry(:filter, data, it, "attackers") }.empty?
|
|
58
|
+
|
|
59
|
+
def pending_phases?(entry)
|
|
60
|
+
entry.is_a?(Hash) && entry.keys.sort == %w[after phases] && added_phases?(entry["phases"]) &&
|
|
61
|
+
Steps::ORDER.any? { it.phase.to_s == entry["after"] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_turn(data, errors)
|
|
65
|
+
errors << "step_state: must be one of #{State::STEP_STATES.join(", ")}" unless State::STEP_STATES.include?(data["step_state"])
|
|
66
|
+
step = data["step"]
|
|
67
|
+
errors << "step: unknown step #{step.inspect}" unless step.nil? || Steps::BY_NAME.key?(step.to_sym)
|
|
68
|
+
if step
|
|
69
|
+
errors << "active: must be 0 or 1" unless [0, 1].include?(data["active"])
|
|
70
|
+
errors << "turn: must be a positive integer" unless data["turn"].is_a?(Integer) && data["turn"].positive?
|
|
71
|
+
errors << "starting_player: must be 0 or 1" unless [0, 1].include?(data["starting_player"])
|
|
72
|
+
end
|
|
73
|
+
validate_pending(data, errors)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_pending(data, errors)
|
|
77
|
+
pending = data["pending"]
|
|
78
|
+
if data["result"]
|
|
79
|
+
errors << "pending: must be empty once the game is over" if pending
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
return errors << "pending: a game in progress needs a pending decision" unless pending.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
errors << "pending.kind: unknown decision #{pending["kind"].inspect}" unless Decisions::KINDS.key?(pending["kind"])
|
|
85
|
+
errors << "pending.player: must be 0 or 1" unless [0, 1].include?(pending["player"])
|
|
86
|
+
return unless pending["kind"] == "priority" && pending["player"] != data["priority"]
|
|
87
|
+
|
|
88
|
+
errors << "pending: priority decision for player #{pending["player"]} but priority is #{data["priority"].inspect}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|