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,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "enjine/deck_builder"
|
|
4
|
+
|
|
5
|
+
module Enjine
|
|
6
|
+
# Checks a folder of card data (require "enjine/verify"): every JSON file below `dir`, each a card or an
|
|
7
|
+
# array of cards. It validates every card, builds random decks from the valid ones (one per color and color
|
|
8
|
+
# pair the pool supports, plus decks that each include a handful of cards, so every card is in some deck),
|
|
9
|
+
# plays rules-monitored games between them (Agents::Busy and Agents::Random seats, export/import round
|
|
10
|
+
# trips), and records which cards were used: cast, played, activated, or had an ability trigger or resolve.
|
|
11
|
+
# Cards still unused get decks of their own, each seated twice, for up to ROUNDS rounds in all.
|
|
12
|
+
#
|
|
13
|
+
# report = Enjine::Verify.new("cards/", games: 20, seed: 1).run
|
|
14
|
+
# report["ok"] # => false if a card is invalid, a rules finding occurred or the engine raised
|
|
15
|
+
# puts Enjine::Verify.text(report)
|
|
16
|
+
#
|
|
17
|
+
# Same folder, games and seed: same report.
|
|
18
|
+
class Verify
|
|
19
|
+
TYPES = %w[creature instant sorcery artifact enchantment land planeswalker battle kindred].freeze
|
|
20
|
+
SPELLS_PER_DECK = 6
|
|
21
|
+
LANDS_PER_DECK = 3
|
|
22
|
+
ROUNDS = 4
|
|
23
|
+
ROUNDTRIP_EVERY = 97
|
|
24
|
+
# Tiny pools still make decks: no outlier land counts, but basics may fill every slot the cards can't.
|
|
25
|
+
PROFILE = { outlier_chance: 0, lands: { outliers: 20..Deck::MIN_SIZE } }.freeze
|
|
26
|
+
PAIRINGS = [%i[busy busy], %i[busy random], %i[random busy]].freeze
|
|
27
|
+
|
|
28
|
+
# One distinct card: where it was first seen, its type names, its definition (nil if invalid).
|
|
29
|
+
Card = Data.define(:name, :file, :index, :types, :definition, :errors)
|
|
30
|
+
|
|
31
|
+
def initialize(dir, games: 10, seed: 0, max_decisions: 20_000)
|
|
32
|
+
raise ArgumentError, "#{dir}: not a directory" unless File.directory?(dir)
|
|
33
|
+
|
|
34
|
+
@dir = dir
|
|
35
|
+
@games = games
|
|
36
|
+
@seed = seed
|
|
37
|
+
@max_decisions = max_decisions
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
@cards = {}
|
|
42
|
+
@invalid = []
|
|
43
|
+
@used = Hash.new(0)
|
|
44
|
+
@results = { findings: [], errors: [], unfinished: [], games: 0, decisions: 0 }
|
|
45
|
+
Dir.glob("**/*.json", base: @dir).sort.each { load_file(it) }
|
|
46
|
+
play_rounds unless valid.empty?
|
|
47
|
+
report
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A readable report.
|
|
51
|
+
def self.text(report)
|
|
52
|
+
out = ["Enjine #{report["engine_version"]} (Comprehensive Rules #{report["rules_version"]}) — seed #{report["seed"]}",
|
|
53
|
+
"#{report["cards"]} valid cards, #{report["games"]} games, #{report["decisions"]} decisions", "",
|
|
54
|
+
"type total valid exercised"]
|
|
55
|
+
report["types"].each { |type, n| out << format("%-14s %6d %6d %10d", type, n["total"], n["valid"], n["exercised"]) }
|
|
56
|
+
section(out, "Invalid cards", report["invalid"]) do
|
|
57
|
+
"#{it["file"]}#{"[#{it["index"]}]" if it["index"]} #{it["name"]}: #{it["errors"].join("; ")}"
|
|
58
|
+
end
|
|
59
|
+
section(out, "Never exercised", report["unexercised"]) { "#{it["name"]} (#{it["file"]})" }
|
|
60
|
+
section(out, "Rules monitor findings", report["findings"]) { "seed #{it["seed"]}: #{it["message"]} — #{it["decks"].join(" vs ")}" }
|
|
61
|
+
section(out, "Engine errors", report["errors"]) { "seed #{it["seed"]}: #{it["message"]}" }
|
|
62
|
+
section(out, "Unfinished games (decision cap)", report["unfinished"]) { "seed #{it["seed"]}" }
|
|
63
|
+
out << "" << (report["ok"] ? "OK" : "FAILED")
|
|
64
|
+
out.join("\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.section(out, title, rows, &)
|
|
68
|
+
return if rows.empty?
|
|
69
|
+
|
|
70
|
+
out << "" << "#{title} (#{rows.size}):"
|
|
71
|
+
rows.each { out << " #{yield it}" }
|
|
72
|
+
end
|
|
73
|
+
private_class_method :section
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def valid = @cards.values.select(&:definition)
|
|
78
|
+
|
|
79
|
+
# --- loading --------------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def load_file(file)
|
|
82
|
+
data = JSON.parse(File.read(File.join(@dir, file)))
|
|
83
|
+
data.is_a?(Array) ? data.each_with_index { |card, i| add(file, i, card) } : add(file, nil, data)
|
|
84
|
+
rescue JSON::ParserError => e
|
|
85
|
+
@invalid << invalid(file, nil, nil, [], ["not valid JSON: #{e.message.lines.first.strip}"])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def add(file, index, data)
|
|
89
|
+
return @invalid << invalid(file, index, nil, [], ["a card must be a JSON object"]) unless data.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
defn = CardDefinition.from_h(data)
|
|
92
|
+
held = @cards[defn.id]
|
|
93
|
+
return @cards[defn.id] = Card.new(defn.name, file, index, type_names(data), defn, []) if held.nil?
|
|
94
|
+
return if held.definition == defn # a reprint: the same card
|
|
95
|
+
|
|
96
|
+
@invalid << invalid(file, index, defn.name, type_names(data), ["differs from #{defn.name} in #{held.file}"])
|
|
97
|
+
rescue InvalidCardDefinition => e
|
|
98
|
+
@invalid << invalid(file, index, data["name"], type_names(data), e.errors)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def invalid(file, index, name, types, errors) = Card.new(name, file, index, types, nil, errors)
|
|
102
|
+
|
|
103
|
+
# Type names from the raw data, so an invalid card still counts: its own, or all its faces'.
|
|
104
|
+
def type_names(data)
|
|
105
|
+
types = [data, *(data["faces"].is_a?(Array) ? data["faces"] : [])].flat_map { it.is_a?(Hash) ? Array(it["types"]) : [] }
|
|
106
|
+
types.grep(String).map(&:downcase).uniq
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# --- decks and games ------------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def play_rounds
|
|
112
|
+
@rng = Rng.from_seed(@seed)
|
|
113
|
+
@builder = DeckBuilder.new(valid.map(&:definition))
|
|
114
|
+
decks = coverage_decks(valid) + variety_decks
|
|
115
|
+
ROUNDS.times do |round|
|
|
116
|
+
play(decks, round.zero? ? @games : decks.size)
|
|
117
|
+
rest = valid.reject { @used.key?(it.definition.id) }
|
|
118
|
+
break if rest.empty?
|
|
119
|
+
|
|
120
|
+
decks = coverage_decks(rest)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# One deck per color and color pair the pool can fill.
|
|
125
|
+
def variety_decks
|
|
126
|
+
combos = Mana::COLORS.combination(1).to_a + Mana::COLORS.combination(2).to_a
|
|
127
|
+
combos.filter_map do |colors|
|
|
128
|
+
@builder.build(colors: colors, seed: @rng.rand(2**31), profile: PROFILE)
|
|
129
|
+
rescue DeckBuilder::NotEnoughCards
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Decks that together include every card in `cards`: grouped by the colors each needs (a land: the colors it
|
|
135
|
+
# makes), a few spells and lands per deck. Colorless groups get random colors.
|
|
136
|
+
def coverage_decks(cards)
|
|
137
|
+
defns = cards.map(&:definition).reject { DeckBuilder.basic?(it) } # every deck has them
|
|
138
|
+
groups = defns.group_by { it.land? ? DeckBuilder.produced_colors(it) : DeckBuilder.required_colors(it) }
|
|
139
|
+
groups.sort_by { |colors, _| colors.join }.flat_map do |colors, defns|
|
|
140
|
+
lands, spells = defns.partition(&:land?)
|
|
141
|
+
spell_chunks = spells.each_slice(SPELLS_PER_DECK).to_a
|
|
142
|
+
land_chunks = lands.each_slice(LANDS_PER_DECK).to_a
|
|
143
|
+
Array.new([spell_chunks.size, land_chunks.size].max) do |i|
|
|
144
|
+
including = (spell_chunks[i] || []) + (land_chunks[i] || [])
|
|
145
|
+
coverage_deck(colors.empty? ? :any : colors, including.map(&:id))
|
|
146
|
+
end
|
|
147
|
+
end.compact
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def coverage_deck(colors, including)
|
|
151
|
+
@builder.build(colors: colors, seed: @rng.rand(2**31), profile: PROFILE, including: including)
|
|
152
|
+
rescue Error, ArgumentError => e
|
|
153
|
+
@results[:errors] << { "seed" => nil, "message" => "building a deck with #{including.join(", ")}: #{e.message}" }
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Seats every deck at least once, and plays at least `minimum` games.
|
|
158
|
+
def play(decks, minimum)
|
|
159
|
+
return if decks.empty?
|
|
160
|
+
|
|
161
|
+
[minimum, (decks.size + 1) / 2].max.times do |i|
|
|
162
|
+
game_seed = @seed + @results[:games]
|
|
163
|
+
@results[:games] += 1
|
|
164
|
+
play_game([decks[(2 * i) % decks.size], decks[((2 * i) + 1) % decks.size]], game_seed)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The rules monitor checks every decision; Snapshot invariants are the fuzz task's (they nearly halve the speed).
|
|
169
|
+
def play_game(decks, seed)
|
|
170
|
+
players = decks.each_with_index.map { |deck, i| Player.new(name: "p#{i + 1}", deck: deck) }
|
|
171
|
+
game = Setup.new(*players).new_game(seed: seed)
|
|
172
|
+
game.subscribe { use(it) }
|
|
173
|
+
@results[:decisions] += Fuzz.play(game, seats(game, seed), seed, @max_decisions, ROUNDTRIP_EVERY, false, RulesMonitor.new(game))
|
|
174
|
+
rescue Fuzz::Failure => e
|
|
175
|
+
@results[:decisions] += e.decisions
|
|
176
|
+
failure(e, decks)
|
|
177
|
+
rescue StandardError => e # setting the game up, before Fuzz.play wraps errors
|
|
178
|
+
@results[:errors] << { "seed" => seed, "message" => "#{e.class}: #{e.message}" }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def failure(error, decks)
|
|
182
|
+
row = { "seed" => error.seed, "decisions" => error.decisions, "message" => error.message,
|
|
183
|
+
"decks" => decks.map { it["description"] } }
|
|
184
|
+
case error.kind
|
|
185
|
+
when :finding then @results[:findings] << row
|
|
186
|
+
when :stall then @results[:unfinished] << row
|
|
187
|
+
else @results[:errors] << row
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# An agent for each seat, by the game's place in PAIRINGS.
|
|
192
|
+
def seats(game, seed)
|
|
193
|
+
agents = game.names.zip(PAIRINGS[seed % PAIRINGS.size]).to_h do |name, kind|
|
|
194
|
+
[name, (kind == :busy ? Agents::Busy : Agents::Random).new(seed: seed + name.sum)]
|
|
195
|
+
end
|
|
196
|
+
Seats.new(agents)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
Seats = Data.define(:agents) do
|
|
200
|
+
def choose(decision) = agents.fetch(decision.player).choose(decision)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def use(event)
|
|
204
|
+
card = case event
|
|
205
|
+
when Events::SpellCast, Events::AbilityActivated, Events::AbilityTriggered, Events::Resolved then event.card
|
|
206
|
+
when Events::ZoneChanged then event.card if event.reason == "play_land"
|
|
207
|
+
end
|
|
208
|
+
@used[card] += 1 if card
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# --- report ---------------------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def report
|
|
214
|
+
unused = valid.reject { @used.key?(it.definition.id) }
|
|
215
|
+
{ "engine_version" => VERSION, "rules_version" => RULES_VERSION, "seed" => @seed, "cards" => valid.size,
|
|
216
|
+
"games" => @results[:games], "decisions" => @results[:decisions], "types" => type_counts,
|
|
217
|
+
"invalid" => @invalid.map { { "file" => it.file, "index" => it.index, "name" => it.name, "errors" => it.errors } },
|
|
218
|
+
"unexercised" => unused.sort_by(&:name).map { { "name" => it.name, "file" => it.file } },
|
|
219
|
+
"findings" => @results[:findings], "errors" => @results[:errors], "unfinished" => @results[:unfinished],
|
|
220
|
+
"ok" => @invalid.empty? && @results[:findings].empty? && @results[:errors].empty? }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def type_counts
|
|
224
|
+
all = @cards.values + @invalid
|
|
225
|
+
types = (TYPES & all.flat_map(&:types)) + (all.flat_map(&:types).uniq - TYPES).sort
|
|
226
|
+
types.to_h do |type|
|
|
227
|
+
cards = all.select { it.types.include?(type) }
|
|
228
|
+
exercised = cards.count { it.definition && @used.key?(it.definition.id) }
|
|
229
|
+
[type, { "total" => cards.size, "valid" => cards.count(&:definition), "exercised" => exercised }]
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
data/lib/enjine/view.rb
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# Builds what a viewer may see of a game. A viewer is a player name or :observer.
|
|
5
|
+
#
|
|
6
|
+
# A player sees public zones, their own hand, and counts of hidden zones.
|
|
7
|
+
# The observer sees everything, including both hands and library order.
|
|
8
|
+
module View
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def build(engine, viewer)
|
|
12
|
+
state = engine.state
|
|
13
|
+
observer = viewer == :observer
|
|
14
|
+
{
|
|
15
|
+
"viewer" => observer ? "observer" : viewer,
|
|
16
|
+
"turn" => state.turn,
|
|
17
|
+
"active" => state.active && engine.name(state.active),
|
|
18
|
+
"step" => state.step&.to_s,
|
|
19
|
+
"phase" => state.step && Steps.fetch(state.step).phase.to_s,
|
|
20
|
+
"priority" => state.priority && engine.name(state.priority),
|
|
21
|
+
"players" => state.players.map { |pl| player_view(engine, pl, observer || pl.name == viewer, observer) },
|
|
22
|
+
"battlefield" => state.battlefield.map { object_view(engine, it) },
|
|
23
|
+
"stack" => state.stack.map { object_view(engine, it) },
|
|
24
|
+
"exile" => state.exile.map { exiled_view(engine, it, viewer) },
|
|
25
|
+
"phased_out" => state.phased_out.map { object_view(engine, it) },
|
|
26
|
+
"command" => engine.emblems.map { emblem_view(engine, it) },
|
|
27
|
+
"pending" => pending_view(engine, viewer),
|
|
28
|
+
"result" => state.result&.dup,
|
|
29
|
+
"effects" => state.effects.map { it.except("controller").merge("controller" => engine.name(it["controller"])) },
|
|
30
|
+
"delayed_triggers" => state.delayed_triggers.map { delayed_view(engine, it) }
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# An emblem in the command zone (CR 114): public, with its owner and abilities.
|
|
35
|
+
def emblem_view(engine, obj)
|
|
36
|
+
{ "id" => obj.id, "kind" => "emblem", "owner" => engine.name(obj.owner),
|
|
37
|
+
"abilities" => engine.chars(obj).abilities.map { it.ability.to_s } }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A delayed triggered ability waiting for its event (CR 603.7): public, like the spell that made it.
|
|
41
|
+
def delayed_view(engine, entry)
|
|
42
|
+
defn = engine.catalog.fetch(entry["card"])
|
|
43
|
+
{ "controller" => engine.name(entry["controller"]), "card" => defn.name, "ability" => defn.ability(entry["ability"]).to_s }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# How a mana kept apart in the pool may be spent and how long it stays (CR 106.6, 702.189a).
|
|
47
|
+
def pool_entry_text(entry)
|
|
48
|
+
parts = [entry["spend_only"] && Mechanics::SpendOnly.from_data(entry["spend_only"]).to_s,
|
|
49
|
+
entry["until"] && "you don't lose this mana until end of combat"]
|
|
50
|
+
parts.compact.join("; ")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def player_view(engine, player, sees_hand, sees_library)
|
|
54
|
+
view = {
|
|
55
|
+
"name" => player.name, "life" => player.life, "poison" => player.poison,
|
|
56
|
+
"mana_pool" => player.mana_pool.reject { |_, n| n.zero? }.transform_keys(&:to_s),
|
|
57
|
+
"restricted_mana" => player.restricted_mana.map { it.merge("text" => View.pool_entry_text(it)) },
|
|
58
|
+
"lands_played" => player.lands_played, "mulligans" => player.mulligans,
|
|
59
|
+
"hand_count" => player.hand.size, "library_count" => player.library.size, "outside_count" => player.outside.size,
|
|
60
|
+
"graveyard" => player.graveyard.map { object_view(engine, it) }
|
|
61
|
+
}
|
|
62
|
+
view.merge!(public_extras(player))
|
|
63
|
+
view["hand"] = player.hand.map { object_view(engine, it) } if sees_hand
|
|
64
|
+
view["outside"] = outside_view(engine, player) if sees_hand # CR 400.11a: only its owner knows their sideboard
|
|
65
|
+
view["library"] = player.library.map { object_view(engine, it) } if sees_library
|
|
66
|
+
view["library_top"] = object_view(engine, player.library.first) if sees_hand && looks_at_top?(engine, player)
|
|
67
|
+
view
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Public player state present only when there is some: speed (CR 702.179), designations (CR 702.195b), player
|
|
71
|
+
# counters other than poison (CR 122.1) and the artifact sources of pool mana (Engine::ManaSources#add_mana!).
|
|
72
|
+
def public_extras(player)
|
|
73
|
+
{ "speed" => player.speed, "designations" => player.designations&.dup, "counters" => player.counters&.dup,
|
|
74
|
+
"pool_sources" => Snapshot.deep_copy(player.pool_sources) }.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The cards a player owns outside the game (CR 400.11a): not objects, so no ids (CR 400.11).
|
|
78
|
+
def outside_view(engine, player)
|
|
79
|
+
player.outside.map do |card|
|
|
80
|
+
defn = engine.catalog.fetch(card)
|
|
81
|
+
{ "card" => card, "name" => defn.name, "type_line" => defn.type_line, "mana_cost" => defn.mana_cost&.to_s }.compact
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# CR 401.5: an effect letting a player look at the top card of their library shows it to that player only.
|
|
86
|
+
def looks_at_top?(engine, player)
|
|
87
|
+
!player.library.empty? && engine.may_look_at_top?(engine.state.players.index(player))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# CR 406.3: a card exiled face down shows only that it's face down, except to an observer and to the
|
|
91
|
+
# players who may look at it.
|
|
92
|
+
def exiled_view(engine, id, viewer)
|
|
93
|
+
obj = engine.object(id)
|
|
94
|
+
return object_view(engine, id) unless engine.face_down?(obj)
|
|
95
|
+
|
|
96
|
+
hidden = { "id" => id, "face_down" => true, "owner" => engine.name(obj.owner) }
|
|
97
|
+
looks = viewer == :observer || obj.memory.fetch("may_look", []).map { engine.name(it) }.include?(viewer)
|
|
98
|
+
looks ? object_view(engine, id).merge(hidden) : hidden
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def object_view(engine, id)
|
|
102
|
+
obj = engine.object(id)
|
|
103
|
+
defn = engine.definition(obj)
|
|
104
|
+
return ability_view(engine, obj, defn) if obj.ability?
|
|
105
|
+
|
|
106
|
+
view = { "id" => id, "card" => obj.card, "name" => defn.name, "type_line" => defn.type_line,
|
|
107
|
+
"owner" => engine.name(obj.owner) }
|
|
108
|
+
view["mana_cost"] = defn.mana_cost.to_s if defn.mana_cost
|
|
109
|
+
view["keywords"] = defn.keywords.map(&:to_s) unless defn.keywords.empty?
|
|
110
|
+
view["rules_text"] = defn.rules_text if defn.rules_text
|
|
111
|
+
case obj.zone
|
|
112
|
+
when :battlefield then permanent_view(engine, obj, defn, view)
|
|
113
|
+
when :stack then view.merge!("controller" => engine.name(obj.controller), "targets" => (obj.targets || []).flatten)
|
|
114
|
+
when :phased_out # public: still on the battlefield, treated as though it doesn't exist (CR 702.26b)
|
|
115
|
+
view.merge!("controller" => engine.name(obj.controller), "tapped" => obj.tapped, "phased_out" => true)
|
|
116
|
+
view["attached_to"] = obj.attached_to if obj.attached_to
|
|
117
|
+
end
|
|
118
|
+
view
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def permanent_view(engine, obj, defn, view)
|
|
122
|
+
view["controller"] = engine.name(obj.controller)
|
|
123
|
+
view["tapped"] = obj.tapped
|
|
124
|
+
view["name"] = engine.chars(obj).name # "named Humble Merchant" (CR 612.8)
|
|
125
|
+
keywords = engine.chars(obj).keywords # printed plus granted (CR 613.1f), minus removed
|
|
126
|
+
keywords.empty? ? view.delete("keywords") : view["keywords"] = keywords.map(&:to_s)
|
|
127
|
+
if engine.chars(obj).creature? # e.g. a Spacecraft with enough charge counters (CR 722.2b)
|
|
128
|
+
power, toughness = engine.power_toughness(obj)
|
|
129
|
+
view.merge!("power" => power, "toughness" => toughness, "damage" => obj.damage,
|
|
130
|
+
"summoning_sick" => engine.summoning_sick_for_tap?(obj))
|
|
131
|
+
end
|
|
132
|
+
view["loyalty"] = engine.loyalty(obj) if defn.planeswalker?
|
|
133
|
+
view["counters"] = obj.counters.reject { |_, n| n.zero? } unless obj.counters.values.all?(&:zero?)
|
|
134
|
+
view["attached_to"] = obj.attached_to if obj.attached_to
|
|
135
|
+
view["transformed"] = true if engine.transformed?(obj) # CR 701.27h
|
|
136
|
+
view["prepared"] = true if engine.prepared?(obj) # CR 723.3a
|
|
137
|
+
if (attack = engine.attackers[obj.id])
|
|
138
|
+
view["attacking"] = attack["target"]
|
|
139
|
+
end
|
|
140
|
+
view["blocking"] = engine.state.blockers[obj.id] if engine.state.blockers.key?(obj.id)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def ability_view(engine, obj, defn)
|
|
144
|
+
ability = defn.ability(obj.ability)
|
|
145
|
+
{ "id" => obj.id, "card" => obj.card, "name" => "#{defn.name} ability", "kind" => "ability",
|
|
146
|
+
"ability" => obj.ability, "loyalty_cost" => ability.loyalty_cost, "source" => obj.source,
|
|
147
|
+
"controller" => engine.name(obj.controller), "targets" => (obj.targets || []).flatten }.compact
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def pending_view(engine, viewer)
|
|
151
|
+
pending = engine.state.pending
|
|
152
|
+
return nil unless pending
|
|
153
|
+
|
|
154
|
+
decider = engine.name(pending["player"])
|
|
155
|
+
view = pending.merge("player" => decider)
|
|
156
|
+
return view.except("choices") unless viewer == :observer || viewer == decider # e.g. the cards a search sees
|
|
157
|
+
|
|
158
|
+
view["options"] = engine.options.map { it.transform_keys(&:to_s) }
|
|
159
|
+
view
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
data/lib/enjine.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "enjine/version"
|
|
4
|
+
require_relative "enjine/errors"
|
|
5
|
+
require_relative "enjine/rng"
|
|
6
|
+
require_relative "enjine/mana"
|
|
7
|
+
require_relative "enjine/primitive"
|
|
8
|
+
require_relative "enjine/mechanics"
|
|
9
|
+
# The shared bases load first; a mechanic file requires any other mechanic it builds on.
|
|
10
|
+
%w[selector values effects/base triggers/base filters/base costs/base abilities/stack_abilities].each do |f|
|
|
11
|
+
require_relative "enjine/mechanics/#{f}"
|
|
12
|
+
end
|
|
13
|
+
Dir[File.join(__dir__, "enjine/mechanics/**/*.rb")].each { |f| require f }
|
|
14
|
+
require_relative "enjine/characteristics"
|
|
15
|
+
require "json"
|
|
16
|
+
require "digest"
|
|
17
|
+
require_relative "enjine/tokens"
|
|
18
|
+
require_relative "enjine/card_definition"
|
|
19
|
+
require_relative "enjine/catalog"
|
|
20
|
+
require_relative "enjine/deck"
|
|
21
|
+
require_relative "enjine/player"
|
|
22
|
+
require_relative "enjine/state"
|
|
23
|
+
require_relative "enjine/events"
|
|
24
|
+
require_relative "enjine/steps"
|
|
25
|
+
require_relative "enjine/frames"
|
|
26
|
+
require_relative "enjine/decisions"
|
|
27
|
+
Dir[File.join(__dir__, "enjine/decisions/*.rb")].each { |f| require f }
|
|
28
|
+
require_relative "enjine/actions"
|
|
29
|
+
%w[pass play_land activate_mana_ability cast special_action].each { |f| require_relative "enjine/actions/#{f}" }
|
|
30
|
+
require_relative "enjine/replacements"
|
|
31
|
+
require_relative "enjine/engine"
|
|
32
|
+
Dir[File.join(__dir__, "enjine/engine/**/*.rb")].each { |f| require f }
|
|
33
|
+
require_relative "enjine/snapshot"
|
|
34
|
+
require_relative "enjine/history"
|
|
35
|
+
require_relative "enjine/view"
|
|
36
|
+
require_relative "enjine/game"
|
|
37
|
+
require_relative "enjine/setup"
|
|
38
|
+
|
|
39
|
+
# Enjine: a deterministic, rules-accurate Magic: The Gathering engine.
|
|
40
|
+
# It knows no cards: every card arrives as data, in a deck or a DeckBuilder pool.
|
|
41
|
+
module Enjine
|
|
42
|
+
end
|
|
43
|
+
require_relative "enjine/agents/random"
|
|
44
|
+
require_relative "enjine/agents/busy"
|
|
45
|
+
require_relative "enjine/fuzz"
|
|
46
|
+
require_relative "enjine/rules_monitor"
|