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,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class CLI
|
|
5
|
+
# Plain-text rendering of views, decisions, options and events.
|
|
6
|
+
# Works only from the public API (views, history, options), never engine internals.
|
|
7
|
+
class Text
|
|
8
|
+
STEP_NAMES = {
|
|
9
|
+
"untap" => "Untap", "upkeep" => "Upkeep", "draw" => "Draw", "precombat_main" => "Main 1",
|
|
10
|
+
"beginning_of_combat" => "Beginning of Combat", "declare_attackers" => "Declare Attackers",
|
|
11
|
+
"declare_blockers" => "Declare Blockers", "combat_damage" => "Combat Damage", "end_of_combat" => "End of Combat",
|
|
12
|
+
"postcombat_main" => "Main 2", "end" => "End Step", "cleanup" => "Cleanup"
|
|
13
|
+
}.freeze
|
|
14
|
+
# CR 500.1: the phases of a turn and their steps (main phases have none), as the board's turn tracker.
|
|
15
|
+
PHASES = [["Beginning", { "untap" => "untap", "upkeep" => "upkeep", "draw" => "draw" }],
|
|
16
|
+
["Main 1", { "precombat_main" => nil }],
|
|
17
|
+
["Combat", { "beginning_of_combat" => "begin", "declare_attackers" => "attackers",
|
|
18
|
+
"declare_blockers" => "blockers", "combat_damage" => "damage", "end_of_combat" => "end" }],
|
|
19
|
+
["Main 2", { "postcombat_main" => nil }],
|
|
20
|
+
["End", { "end" => "end step", "cleanup" => "cleanup" }]].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(game)
|
|
23
|
+
@game = game
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# ---- board -----------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def board(view)
|
|
29
|
+
lines = [rule(turn_title(view["turn"], view["active"], view["step"])), " #{tracker(view["step"].to_s)}"]
|
|
30
|
+
lines << " Priority: #{view["priority"]}" if view["priority"]
|
|
31
|
+
viewer = view["viewer"]
|
|
32
|
+
view["players"].sort_by { it["name"] == viewer ? 1 : 0 }.each { lines.concat(player_block(view, it)) }
|
|
33
|
+
lines << " Stack (top first): #{"empty" if view["stack"].empty?}"
|
|
34
|
+
view["stack"].each { lines << " #{stack_item(it)}" }
|
|
35
|
+
view["effects"].each do |e|
|
|
36
|
+
lines << " Effect: +#{e["power"]}/+#{e["toughness"]} until end of turn on #{e["affected"].map do
|
|
37
|
+
name_of(it)
|
|
38
|
+
end.join(", ")}"
|
|
39
|
+
end
|
|
40
|
+
lines.join("\n")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# "Turn 3 · alice's turn · Upkeep", or "Pregame" before the first turn.
|
|
44
|
+
def turn_title(turn, active, step)
|
|
45
|
+
return "Pregame" if step.nil? || active.nil?
|
|
46
|
+
|
|
47
|
+
"Turn #{turn} · #{active}'s turn · #{STEP_NAMES.fetch(step.to_s, step.to_s)}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The phases of the turn with the current one marked, its steps listed:
|
|
51
|
+
# "Beginning › ▶MAIN 1 › Combat › Main 2 › End" or
|
|
52
|
+
# "Beginning › Main 1 › ▶COMBAT (begin · attackers · [blockers] · damage · end) › Main 2 › End".
|
|
53
|
+
def tracker(step)
|
|
54
|
+
return "Mulligans and play/draw choices" unless STEP_NAMES.key?(step)
|
|
55
|
+
|
|
56
|
+
PHASES.map do |phase, steps|
|
|
57
|
+
next phase unless steps.key?(step)
|
|
58
|
+
|
|
59
|
+
inner = steps.values.compact.map { |name| steps[step] == name ? "[#{name}]" : name }
|
|
60
|
+
"▶#{phase.upcase}#{" (#{inner.join(" · ")})" unless inner.empty?}"
|
|
61
|
+
end.join(" › ")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# "Combat › Declare Attackers", "Main 1".
|
|
65
|
+
def step_line(step)
|
|
66
|
+
phase, steps = PHASES.find { |_, names| names.key?(step) }
|
|
67
|
+
name = STEP_NAMES.fetch(step, step)
|
|
68
|
+
phase.nil? || steps[step].nil? ? name : "#{phase} › #{name}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def player_block(view, player)
|
|
72
|
+
pool = player["mana_pool"].map { |t, n| "{#{t}}" * n }.join + player.fetch("restricted_mana", []).map { "{#{it["mana"]}}*" }.join
|
|
73
|
+
poison = player["poison"].positive? ? " · poison #{player["poison"]}" : ""
|
|
74
|
+
lines = ["", " #{player["name"].upcase} — life #{player["life"]}#{poison} · " \
|
|
75
|
+
"library #{player["library_count"]} · hand #{player["hand_count"]} · " \
|
|
76
|
+
"graveyard #{player["graveyard"].size}#{" · mana pool #{pool}" unless pool.empty?}"]
|
|
77
|
+
perms = view["battlefield"].select { it["controller"] == player["name"] }
|
|
78
|
+
lands, others = perms.partition { it["type_line"].include?("Land") }
|
|
79
|
+
lines << " Lands: #{lands.empty? ? "-" : lands.map { permanent(it) }.join(", ")}"
|
|
80
|
+
lines << " Permanents: #{"-" if others.empty?}"
|
|
81
|
+
others.each { lines << " #{permanent(it)}" }
|
|
82
|
+
lines << " Hand: #{player["hand"].map { card(it) }.join(", ")}" if player["hand"]
|
|
83
|
+
if player["library"]
|
|
84
|
+
lines << " Library (top first): #{player["library"].first(5).map do
|
|
85
|
+
it["name"]
|
|
86
|
+
end.join(", ")}#{" …" if player["library_count"] > 5}"
|
|
87
|
+
end
|
|
88
|
+
lines << " Graveyard: #{player["graveyard"].map { it["name"] }.join(", ")}" unless player["graveyard"].empty?
|
|
89
|
+
lines
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def permanent(obj)
|
|
93
|
+
parts = [obj["name"]]
|
|
94
|
+
parts << "#{obj["power"]}/#{obj["toughness"]}" if obj.key?("power")
|
|
95
|
+
parts << "loyalty #{obj["loyalty"]}" if obj.key?("loyalty")
|
|
96
|
+
parts << obj["keywords"].map { it.tr("_", " ") }.join(", ") if obj["keywords"]
|
|
97
|
+
parts << "damage #{obj["damage"]}" if obj["damage"].to_i.positive?
|
|
98
|
+
obj.fetch("counters", {}).except("loyalty").each { |kind, n| parts << "#{n}×#{kind}" }
|
|
99
|
+
parts << "tapped" if obj["tapped"]
|
|
100
|
+
parts << "sick" if obj["summoning_sick"]
|
|
101
|
+
parts << "attacking #{name_of(obj["attacking"])}" if obj["attacking"]
|
|
102
|
+
parts << "blocking #{name_of(obj["blocking"])}" if obj["blocking"]
|
|
103
|
+
"#{parts.join(" · ")} [#{obj["id"]}]"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def card(obj) = "#{obj["name"]}#{" #{obj["mana_cost"]}" if obj["mana_cost"]} [#{obj["id"]}]"
|
|
107
|
+
|
|
108
|
+
def stack_item(obj)
|
|
109
|
+
targets = obj["targets"].empty? ? "" : " → #{obj["targets"].map { name_of(it) }.join(", ")}"
|
|
110
|
+
"#{obj["name"]}#{" (#{"+" if obj["loyalty_cost"].to_i.positive?}#{obj["loyalty_cost"]})" if obj["loyalty_cost"]} " \
|
|
111
|
+
"controlled by #{obj["controller"]}#{targets} [#{obj["id"]}]"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# ---- decisions & options -------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def decision(decision)
|
|
117
|
+
extra = case decision.kind
|
|
118
|
+
when "pay_cost" then " — left to pay: #{decision.details["remaining"]}"
|
|
119
|
+
when "discard" then " — discard #{decision.details["count"]} more"
|
|
120
|
+
when "bottom" then " — put #{decision.details["count"]} more on the bottom"
|
|
121
|
+
when "assign_damage" then " — #{name_of(decision.details["attacker"])}: #{decision.details["remaining"]} damage left"
|
|
122
|
+
when "choose_target" then " — target ##{decision.details["index"] + 1}"
|
|
123
|
+
when "choose_target_count" then " — how many for target ##{decision.details["index"] + 1}"
|
|
124
|
+
else ""
|
|
125
|
+
end
|
|
126
|
+
"▶ #{decision.player.upcase} must act: #{decision.kind.tr("_", " ")}#{extra} (#{turn_title(
|
|
127
|
+
@game.turn, @game.state.active && @game.names[@game.state.active], @game.step
|
|
128
|
+
)})"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def option(opt)
|
|
132
|
+
case opt[:type]
|
|
133
|
+
when "pass" then "Pass priority"
|
|
134
|
+
when "play_land" then "Play land #{name_of(opt[:object])}"
|
|
135
|
+
when "cast" then "Cast #{name_of(opt[:object])} #{cost_of(opt[:object])}".rstrip
|
|
136
|
+
when "activate" then "Activate #{name_of(opt[:object])}: #{ability_text(opt[:object], opt[:ability])}"
|
|
137
|
+
when "special_action" then "#{ability_text(opt[:object], opt[:ability])}: #{name_of(opt[:object])} (special action)"
|
|
138
|
+
when "activate_mana"
|
|
139
|
+
"Tap #{name_of(opt[:object])} for #{opt[:mana] || "mana"} (#{ability_text(opt[:object], opt[:ability])})"
|
|
140
|
+
when "pay_mana"
|
|
141
|
+
"Pay {#{opt[:mana]}} from mana pool#{" (restricted)" if opt[:restricted]}#{" (made by an artifact)" if opt[:from]}"
|
|
142
|
+
when "target" then "Target #{name_of(opt[:target])}"
|
|
143
|
+
when "cost" then "Use #{name_of(opt[:object])} to pay the cost"
|
|
144
|
+
when "behold" then "Behold #{name_of(opt[:object])}"
|
|
145
|
+
when "pay" then opt[:cost] ? "Pay: #{opt[:cost]}" : "Pay"
|
|
146
|
+
when "cost_option" then @game.pending_decision.details["costs"][opt[:option]]
|
|
147
|
+
when "cancel" then "Cancel (rewind this cast/activation)"
|
|
148
|
+
when "attack" then "Attack #{name_of(opt[:target])} with #{name_of(opt[:object])}"
|
|
149
|
+
when "block" then "Block #{name_of(opt[:attacker])} with #{name_of(opt[:object])}"
|
|
150
|
+
when "assign" then "Assign 1 damage to #{name_of(opt[:object])}"
|
|
151
|
+
when "done" then "Done"
|
|
152
|
+
when "source" then "Choose #{name_of(opt[:object])} as the source"
|
|
153
|
+
when "devour" then "Devour #{name_of(opt[:object])}"
|
|
154
|
+
when "discard", "bottom", "find" then "#{opt[:type].capitalize} #{name_of(opt[:object])}"
|
|
155
|
+
when "keep" then "Keep hand"
|
|
156
|
+
when "call" then "Call #{opt[:call]}"
|
|
157
|
+
when "mulligan" then "Mulligan"
|
|
158
|
+
when "play", "draw" then "#{opt[:type].capitalize} first"
|
|
159
|
+
else opt.inspect
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ---- events --------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def events(list) = list.filter_map { event(it) }
|
|
166
|
+
|
|
167
|
+
def event(ev)
|
|
168
|
+
case ev["type"]
|
|
169
|
+
when "zone_changed" then zone_changed(ev)
|
|
170
|
+
when "turn_began" then "══ Turn #{ev["turn"]}: #{ev["active"]}'s turn ══"
|
|
171
|
+
when "step_began" then "─ #{step_line(ev["step"])}"
|
|
172
|
+
when "priority_passed" then "#{ev["player"]} passes"
|
|
173
|
+
when "tapped" then "#{name_of(ev["object"])} becomes tapped"
|
|
174
|
+
when "untapped" then "#{name_of(ev["object"])} untaps"
|
|
175
|
+
when "mana_added" then "#{ev["player"]} adds #{ev["mana"]}"
|
|
176
|
+
when "mana_paid" then "#{ev["player"]} pays #{ev["mana"]}"
|
|
177
|
+
when "mana_emptied" then "#{ev["player"]}'s unused #{ev["mana"]} empties"
|
|
178
|
+
when "spell_cast" then "#{ev["player"]} casts #{name_of(ev["object"])}#{targets(ev["targets"])}"
|
|
179
|
+
when "ability_activated" then "#{ev["player"]} activates #{name_of(ev["source"])}'s ability#{targets(ev["targets"])}"
|
|
180
|
+
when "target_chosen" then "#{ev["player"]} targets #{name_of(ev["target"])}"
|
|
181
|
+
when "cast_cancelled" then "#{ev["player"]} cancels casting #{card_name(ev["card"])}"
|
|
182
|
+
when "resolved" then "#{card_name(ev["card"])}#{" ability" if ev["kind"] == "ability"} resolves"
|
|
183
|
+
when "fizzled" then "#{card_name(ev["card"])} doesn't resolve: its targets are gone"
|
|
184
|
+
when "damage_dealt" then "#{name_of(ev["source"])} deals #{ev["amount"]} damage to #{name_of(ev["target"])}"
|
|
185
|
+
when "life_changed" then "#{ev["player"]}: life #{ev["from"]} → #{ev["to"]}"
|
|
186
|
+
when "counters_changed" then "#{name_of(ev["object"])}: #{ev["counter"]} counters #{ev["from"]} → #{ev["to"]}"
|
|
187
|
+
when "effect_started" then pumped(ev)
|
|
188
|
+
when "effects_ended" then "#{ev["count"]} until-end-of-turn effect(s) end"
|
|
189
|
+
when "attacker_declared" then "#{name_of(ev["object"])} attacks #{name_of(ev["target"])}"
|
|
190
|
+
when "blocker_declared" then "#{name_of(ev["object"])} blocks #{name_of(ev["attacker"])}"
|
|
191
|
+
when "damage_assigned" then "1 damage of #{name_of(ev["attacker"])} assigned to #{name_of(ev["blocker"])}"
|
|
192
|
+
when "player_lost" then "#{ev["player"]} LOSES (#{ev["reason"]})"
|
|
193
|
+
when "game_ended" then "GAME OVER — #{result(ev)}"
|
|
194
|
+
when "library_shuffled" then "#{ev["player"]} shuffles their library"
|
|
195
|
+
when "card_revealed" then "#{ev["player"]} reveals #{card_name(ev["card"])}"
|
|
196
|
+
when "die_rolled" then "#{ev["winner"]} wins the die roll"
|
|
197
|
+
when "rolled_die" then "#{ev["player"]} rolls a d#{ev["sides"]}: #{ev["result"]}"
|
|
198
|
+
when "starting_player_chosen" then "#{ev["chooser"]} chooses to #{ev["choice"]}: #{ev["starting_player"]} goes first"
|
|
199
|
+
when "mulligan_declared" then "#{ev["player"]} declares: #{ev["choice"]}"
|
|
200
|
+
when "mulligan_taken" then "#{ev["player"]} mulligans (#{ev["mulligans"]} so far)"
|
|
201
|
+
when "hand_kept" then "#{ev["player"]} keeps#{" after #{ev["mulligans"]} mulligan(s)" if ev["mulligans"].positive?}"
|
|
202
|
+
when "game_started" then "Game started: #{ev["players"].join(" vs ")} (seed #{ev["seed"]})"
|
|
203
|
+
else mechanic_event(ev)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Events of particular mechanics; attacks_declared is told by its attacker_declared events.
|
|
208
|
+
def mechanic_event(ev)
|
|
209
|
+
case ev["type"]
|
|
210
|
+
when "attacks_declared" then nil
|
|
211
|
+
when "ability_triggered" then "#{card_name(ev["card"])}'s ability triggers"
|
|
212
|
+
when "trigger_put_on_stack" then "#{card_name(ev["card"])}'s triggered ability goes on the stack#{targets(ev["targets"])}"
|
|
213
|
+
when "trigger_removed" then "#{card_name(ev["card"])}'s triggered ability is removed (#{ev["reason"]})"
|
|
214
|
+
when "ability_countered" then "#{card_name(ev["card"])}'s ability is countered"
|
|
215
|
+
when "spell_copied" then "#{ev["player"]} copies #{card_name(ev["card"])}#{targets(ev["targets"])}"
|
|
216
|
+
when "x_announced" then "#{ev["player"]} announces X = #{ev["value"]}"
|
|
217
|
+
when "payment_finished" then "#{ev["player"]} #{ev["outcome"]}"
|
|
218
|
+
when "attached" then "#{name_of(ev["object"])} is attached to #{name_of(ev["to"])}"
|
|
219
|
+
when "unattached" then "#{name_of(ev["object"])} becomes unattached from #{name_of(ev["from"])}"
|
|
220
|
+
when "control_changed" then "#{ev["controller"]} gains control of #{name_of(ev["object"])}"
|
|
221
|
+
when "became_blocked" then "#{name_of(ev["object"])} is blocked"
|
|
222
|
+
when "crewed_vehicle" then "#{name_of(ev["object"])} crews #{name_of(ev["vehicle"])}"
|
|
223
|
+
when "tapped_for_cost" then "#{name_of(ev["object"])} is tapped to pay a #{ev["cost"]} cost"
|
|
224
|
+
when "became_crewed" then "#{name_of(ev["object"])} becomes crewed"
|
|
225
|
+
when "transformed" then "#{name_of(ev["object"])} transforms into #{card_name(ev["card"])}"
|
|
226
|
+
when "prepared_changed" then "#{name_of(ev["object"])} #{ev["prepared"] ? "becomes prepared" : "is no longer prepared"}"
|
|
227
|
+
when "phased_out" then "#{name_of(ev["object"])} phases out"
|
|
228
|
+
when "phased_in" then "#{name_of(ev["object"])} phases in"
|
|
229
|
+
when "token_ceased" then "#{card_name(ev["card"])} token ceases to exist"
|
|
230
|
+
when "library_reordered" then "#{ev["player"]} puts #{Array(ev["top"]).size} on top and #{Array(ev["bottom"]).size} on the bottom"
|
|
231
|
+
when "looked_at" then "#{ev["player"]} looks at #{Array(ev["objects"]).size} of #{ev["owner"]}'s cards"
|
|
232
|
+
when "dilemma_faced" then "#{ev["player"]} faces a dilemma"
|
|
233
|
+
when "extra_turn_added" then "#{ev["player"]} will take an extra turn"
|
|
234
|
+
when "turn_skipped" then "#{ev["player"]} skips a turn"
|
|
235
|
+
when "emblem_created" then "#{ev["player"]} gets an emblem from #{card_name(ev["card"])}"
|
|
236
|
+
when "player_counters_changed" then "#{ev["player"]}: #{ev["counter"]} counters #{ev["from"]} → #{ev["to"]}"
|
|
237
|
+
when "speed_changed" then "#{ev["player"]}: speed #{ev["from"]} → #{ev["to"]}"
|
|
238
|
+
when "coin_flipped"
|
|
239
|
+
"#{ev["player"]} flips #{ev["heads"] ? "heads" : "tails"}#{{ true => " and wins", false => " and loses" }[ev["won"]]}"
|
|
240
|
+
when "keyword_action" then "#{ev["player"]} #{ev["action"].to_s.tr("_", " ")}#{" #{ev["amount"]}" if ev["amount"]}"
|
|
241
|
+
else "#{ev["type"]}: #{ev.except("type")}"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def pumped(ev)
|
|
246
|
+
verb = ev["affected"].size == 1 ? "gets" : "get"
|
|
247
|
+
"#{ev["affected"].map do
|
|
248
|
+
name_of(it)
|
|
249
|
+
end.join(", ")} #{verb} +#{ev["details"]["power"]}/+#{ev["details"]["toughness"]} until end of turn"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def zone_changed(ev)
|
|
253
|
+
what = ev["card"] ? card_name(ev["card"]) : "a card"
|
|
254
|
+
case ev["reason"]
|
|
255
|
+
when "draw" then "#{ev["owner"]} draws #{what}"
|
|
256
|
+
when "play_land" then "#{ev["owner"]} plays #{what}"
|
|
257
|
+
when "cast", "mulligan" then nil
|
|
258
|
+
when "resolve" then ev["to"] == "battlefield" ? "#{what} enters the battlefield" : "#{what} goes to the graveyard"
|
|
259
|
+
when "sba_lethal_damage" then "#{what} dies (lethal damage)"
|
|
260
|
+
when "sba_toughness" then "#{what} dies (0 toughness)"
|
|
261
|
+
when "sba_loyalty" then "#{what} is put into the graveyard (0 loyalty)"
|
|
262
|
+
when "countered" then "#{what} is countered"
|
|
263
|
+
when "mulligan_bottom" then "#{ev["owner"]} puts #{what} on the bottom of their library (mulligan)"
|
|
264
|
+
else "#{ev["owner"]}'s #{what}: #{ev["from"]} → #{ev["to"]} (#{ev["reason"]})"
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def result(res) = res["winner"] ? "#{res["winner"]} wins (#{res["reason"]})" : "draw (#{res["reason"]})"
|
|
269
|
+
|
|
270
|
+
# ---- helpers -------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def rule(text) = "━━ #{text} #{"━" * [4, 76 - text.size].max}"
|
|
273
|
+
|
|
274
|
+
def name_of(ref)
|
|
275
|
+
return "-" if ref.nil?
|
|
276
|
+
return ref.delete_prefix("player:") if ref.start_with?("player:")
|
|
277
|
+
|
|
278
|
+
obj = @game.state.objects[ref]
|
|
279
|
+
obj ? "#{@game.catalog.fetch(obj.card).name}#{" ability" if obj.ability?}" : card_name(ref.split("#").first)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def card_name(card_id) = @game.catalog[card_id]&.name || card_id.to_s
|
|
283
|
+
|
|
284
|
+
def cost_of(id) = @game.catalog.fetch(@game.state.object(id).card).mana_cost.to_s
|
|
285
|
+
|
|
286
|
+
# A granted ability ("granted:<card>:<ref>") lives in the granting card's data.
|
|
287
|
+
def ability_text(id, ref)
|
|
288
|
+
card, inner = Engine::AbilityLayers.granted_parts(ref) || [@game.state.object(id).card, ref]
|
|
289
|
+
@game.catalog.fetch(card).ability(inner).to_s
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def targets(list) = list.nil? || list.empty? ? "" : " targeting #{list.map { name_of(it) }.join(", ")}"
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
data/lib/enjine/cli.rb
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "enjine/deck_builder"
|
|
5
|
+
require "enjine/verify"
|
|
6
|
+
require_relative "cli/text"
|
|
7
|
+
|
|
8
|
+
module Enjine
|
|
9
|
+
# Interactive text client. Hot-seat by default: you play both players, see what
|
|
10
|
+
# the deciding player sees, pick numbered options, and after each choice see
|
|
11
|
+
# exactly what happened. Seats can also be "random" (uniform legal choices).
|
|
12
|
+
#
|
|
13
|
+
# enjine --deck1 examples/decks/gruul_beats.json --deck2 examples/decks/azorius_walkers.json
|
|
14
|
+
# enjine --pool examples/pool.json --deck1 random:colors=RG --deck2 random --p2 random
|
|
15
|
+
# enjine --verify cards/ --games 20 --seed 1 # check a folder of card JSON (see Enjine::Verify)
|
|
16
|
+
class CLI
|
|
17
|
+
SEATS = %w[human random].freeze
|
|
18
|
+
HELP = <<~HELP
|
|
19
|
+
Commands:
|
|
20
|
+
<number> choose that option
|
|
21
|
+
b show the board again o toggle observer view (see everything)
|
|
22
|
+
h [n] show the last n history entries (default 10)
|
|
23
|
+
u undo your last decision (replays the game from its seed)
|
|
24
|
+
s FILE save the game to FILE (JSON snapshot)
|
|
25
|
+
c concede for the deciding player
|
|
26
|
+
q quit
|
|
27
|
+
HELP
|
|
28
|
+
|
|
29
|
+
def initialize(argv, input: $stdin, output: $stdout)
|
|
30
|
+
@input = input
|
|
31
|
+
@out = output
|
|
32
|
+
@options = { p1: "human", p2: "human", quiet: false, max_decisions: 100_000,
|
|
33
|
+
observer: false, names: %w[alice bob] }
|
|
34
|
+
parser.parse!(argv)
|
|
35
|
+
# `--random-deck colors=WU seed=7` works unquoted: the key=value words after it belong to the spec.
|
|
36
|
+
@options[:random_deck] = [@options[:random_deck], *argv.grep(/=/)].compact.join(" ") if @options.key?(:random_deck)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def run
|
|
40
|
+
return verify(@options[:verify]) if @options[:verify]
|
|
41
|
+
|
|
42
|
+
@options[:seed] ||= Random.new_seed % (2**31)
|
|
43
|
+
return validate(@options[:validate]) if @options[:validate]
|
|
44
|
+
return random_deck(@options[:random_deck]) if @options.key?(:random_deck)
|
|
45
|
+
|
|
46
|
+
@game = start_game or return 1
|
|
47
|
+
@text = Text.new(@game)
|
|
48
|
+
@seats = @game.names.zip([@options[:p1], @options[:p2]]).to_h do |name, kind|
|
|
49
|
+
[name, kind == "human" ? :human : Agents::Random.new(seed: @options[:seed] + name.sum)]
|
|
50
|
+
end
|
|
51
|
+
intro
|
|
52
|
+
loop_decisions
|
|
53
|
+
say @text.rule("GAME OVER: #{@text.result(@game.result)} · turn #{@game.turn}") if @game.over?
|
|
54
|
+
0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def parser
|
|
60
|
+
OptionParser.new do |o|
|
|
61
|
+
o.banner = "Usage: enjine --deck1 DECK --deck2 DECK [options] (default: hot-seat, you play both players)"
|
|
62
|
+
o.on("--seed N", Integer, "Seed for shuffling and the die roll") { @options[:seed] = it }
|
|
63
|
+
o.on("--p1 SEAT", SEATS, "Seat 1: human (default) or random") { @options[:p1] = it }
|
|
64
|
+
o.on("--p2 SEAT", SEATS, "Seat 2: human (default) or random") { @options[:p2] = it }
|
|
65
|
+
o.on("--deck1 DECK", "Seat 1's deck: a deck document (JSON file), or random[:SPEC] from --pool") { @options[:deck1] = it }
|
|
66
|
+
o.on("--deck2 DECK", "Seat 2's deck: a deck document (JSON file), or random[:SPEC] from --pool") { @options[:deck2] = it }
|
|
67
|
+
o.on("--pool FILE", "Cards random decks are built from: a JSON array of card data blocks") { @options[:pool] = it }
|
|
68
|
+
o.on("--random-deck [SPEC]", "Print a random deck from --pool as JSON, e.g. \"colors=WU seed=7\"") do
|
|
69
|
+
@options[:random_deck] = it
|
|
70
|
+
end
|
|
71
|
+
o.on("--names A,B", Array, "Player names (default alice,bob)") { @options[:names] = it }
|
|
72
|
+
o.on("--validate FILE", "Validate a JSON array of card data blocks; prints a JSON report") { @options[:validate] = it }
|
|
73
|
+
o.on("--verify DIR", "Validate and play-test every card in DIR's JSON files; exit 1 on any problem") do
|
|
74
|
+
@options[:verify] = it
|
|
75
|
+
end
|
|
76
|
+
o.on("--games N", Integer, "With --verify: play at least N games (default 10)") { @options[:games] = it }
|
|
77
|
+
o.on("--json", "With --verify: print the report as JSON") { @options[:json] = true }
|
|
78
|
+
o.on("--load FILE", "Resume a game saved with the s command") { @options[:load] = it }
|
|
79
|
+
o.on("--observer", "Start in observer view (see both hands and library order)") { @options[:observer] = true }
|
|
80
|
+
o.on("--quiet", "Only print the result (useful with random seats)") { @options[:quiet] = true }
|
|
81
|
+
o.on("--max-decisions N", Integer, "Concede after N decisions") { @options[:max_decisions] = it }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Machine-readable validation for card-conversion pipelines. Exit code 1 if anything is invalid.
|
|
86
|
+
def validate(path)
|
|
87
|
+
cards = JSON.parse(File.read(path))
|
|
88
|
+
cards = [cards] if cards.is_a?(Hash)
|
|
89
|
+
report = { "rules_version" => RULES_VERSION, "engine_version" => VERSION, "valid" => [], "invalid" => [] }
|
|
90
|
+
cards.each_with_index do |data, i|
|
|
91
|
+
defn = CardDefinition.from_h(data)
|
|
92
|
+
report["valid"] << { "index" => i, "id" => defn.id, "name" => defn.name, "card" => defn.to_h }
|
|
93
|
+
rescue InvalidCardDefinition => e
|
|
94
|
+
report["invalid"] << { "index" => i, "name" => data.is_a?(Hash) ? data["name"] : nil, "errors" => e.errors }
|
|
95
|
+
end
|
|
96
|
+
say JSON.pretty_generate(report)
|
|
97
|
+
report["invalid"].empty? ? 0 : 1
|
|
98
|
+
rescue JSON::ParserError => e
|
|
99
|
+
say JSON.generate("error" => "not valid JSON: #{e.message}")
|
|
100
|
+
1
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Checks a folder of cards (seed 0 unless --seed); exit code 1 if a card is invalid or a game went wrong.
|
|
104
|
+
def verify(dir)
|
|
105
|
+
report = Verify.new(dir, games: @options.fetch(:games, 10), seed: @options.fetch(:seed, 0)).run
|
|
106
|
+
say @options[:json] ? JSON.pretty_generate(report) : Verify.text(report)
|
|
107
|
+
report["ok"] ? 0 : 1
|
|
108
|
+
rescue ArgumentError => e
|
|
109
|
+
say "error: #{e.message}"
|
|
110
|
+
1
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Prints a random deck document; exit code 1 with a message if it can't be built.
|
|
114
|
+
def random_deck(spec)
|
|
115
|
+
say JSON.pretty_generate(build_random_deck(spec, @options[:seed]))
|
|
116
|
+
0
|
|
117
|
+
rescue Error, ArgumentError, SystemCallError, JSON::ParserError => e
|
|
118
|
+
say "error: #{e.message}"
|
|
119
|
+
1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# SPEC: whitespace- or comma-separated key=value pairs; a bare word continues the previous
|
|
123
|
+
# value, so "colors=W,U,seed=7" works. Keys: colors (letters like WU, a count 1-5, or any), seed, size.
|
|
124
|
+
def build_random_deck(spec, default_seed)
|
|
125
|
+
args = parse_spec(spec.to_s)
|
|
126
|
+
deck = deck_builder.build(colors: args.delete("colors") || :any, seed: Integer(args.delete("seed") || default_seed),
|
|
127
|
+
size: Integer(args.delete("size") || DeckBuilder::BASE_SIZE))
|
|
128
|
+
raise ArgumentError, "unknown random deck option(s): #{args.keys.join(", ")}" unless args.empty?
|
|
129
|
+
|
|
130
|
+
deck
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def parse_spec(spec)
|
|
134
|
+
key = nil
|
|
135
|
+
spec.split(/[\s,;]+/).reject(&:empty?).each_with_object({}) do |word, out|
|
|
136
|
+
if word.include?("=")
|
|
137
|
+
key, value = word.split("=", 2)
|
|
138
|
+
out[key] = value
|
|
139
|
+
elsif key
|
|
140
|
+
out[key] = "#{out[key]},#{word}"
|
|
141
|
+
else
|
|
142
|
+
raise ArgumentError, "random deck spec: expected key=value, got #{word.inspect}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# One builder over the --pool, for every random deck of this run.
|
|
148
|
+
def deck_builder
|
|
149
|
+
raise ArgumentError, "random decks need --pool FILE (a JSON array of cards)" unless @options[:pool]
|
|
150
|
+
|
|
151
|
+
@deck_builder ||= DeckBuilder.new(JSON.parse(File.read(@options[:pool])))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def start_game
|
|
155
|
+
@options[:load] ? load_game : new_game
|
|
156
|
+
rescue Error, ArgumentError, SystemCallError, JSON::ParserError => e
|
|
157
|
+
say "error: #{e.message}"
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def new_game
|
|
162
|
+
decks = [@options[:deck1], @options[:deck2]].each_with_index.map { |deck, i| deck_for(deck, i) }
|
|
163
|
+
players = @options[:names].zip(decks).map { |name, deck| Player.new(name: name, deck: deck) }
|
|
164
|
+
@setup = Setup.new(*players)
|
|
165
|
+
@setup.new_game(seed: @options[:seed])
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A deck file, or "random[:SPEC]" for one built from --pool (seed defaults to game seed + seat).
|
|
169
|
+
# Remembers each deck's label for the intro.
|
|
170
|
+
def deck_for(deck, seat)
|
|
171
|
+
raise ArgumentError, "pass --deck#{seat + 1}: a deck file (see examples/decks) or random[:SPEC] with --pool" if deck.nil?
|
|
172
|
+
|
|
173
|
+
if deck.start_with?("random")
|
|
174
|
+
doc = build_random_deck(deck.delete_prefix("random").delete_prefix(":"), @options[:seed] + seat)
|
|
175
|
+
@options[:"deck#{seat + 1}"] = doc["description"]
|
|
176
|
+
return doc
|
|
177
|
+
end
|
|
178
|
+
JSON.parse(File.read(deck)).tap { @options[:"deck#{seat + 1}"] = it["name"] || File.basename(deck, ".json") }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# A saved game carries its cards (see #save), so it resumes without the decks.
|
|
182
|
+
def load_game
|
|
183
|
+
data = JSON.parse(File.read(@options[:load]))
|
|
184
|
+
cards = data.fetch("cards") { raise ArgumentError, "#{@options[:load]}: not a game saved with the s command" }
|
|
185
|
+
Game.import(data, catalog: Catalog.from_definitions(cards))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def intro
|
|
189
|
+
return if @options[:quiet]
|
|
190
|
+
|
|
191
|
+
say "Enjine #{VERSION} (Comprehensive Rules #{RULES_VERSION}) — seed #{@game.state.seed}"
|
|
192
|
+
say "#{@game.names[0]}: #{@options[:deck1]} #{@game.names[1]}: #{@options[:deck2]} — type ? for help"
|
|
193
|
+
show_events(@game.history.last) if @game.history.last
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def loop_decisions
|
|
197
|
+
count = 0
|
|
198
|
+
until @game.over?
|
|
199
|
+
if count >= @options[:max_decisions]
|
|
200
|
+
@game.concede(@game.pending_decision.player)
|
|
201
|
+
break
|
|
202
|
+
end
|
|
203
|
+
decision = @game.pending_decision
|
|
204
|
+
seat = @seats.fetch(decision.player)
|
|
205
|
+
auto = seat == :human && only_pass?(decision) # nothing else to do: pass for them
|
|
206
|
+
option = if seat != :human then seat.choose(decision)
|
|
207
|
+
elsif auto then { type: "pass" }
|
|
208
|
+
else ask(decision)
|
|
209
|
+
end
|
|
210
|
+
break if option == :quit
|
|
211
|
+
next if option == :again
|
|
212
|
+
|
|
213
|
+
entry = @game.submit(option)
|
|
214
|
+
count += 1
|
|
215
|
+
show_events(entry, auto:) unless @options[:quiet]
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def ask(decision)
|
|
220
|
+
show_board(decision)
|
|
221
|
+
loop do
|
|
222
|
+
@out.print "#{decision.player}> "
|
|
223
|
+
line = @input.gets
|
|
224
|
+
return :quit if line.nil?
|
|
225
|
+
|
|
226
|
+
result = command(line.strip, decision)
|
|
227
|
+
return result unless result.nil?
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def show_board(decision)
|
|
232
|
+
say @text.board(@game.view_for(viewer(decision)))
|
|
233
|
+
say ""
|
|
234
|
+
say @text.decision(decision)
|
|
235
|
+
decision.options.each_with_index { |opt, i| say format(" %2d) %s", i + 1, @text.option(opt)) }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def viewer(decision) = @options[:observer] ? :observer : decision.player
|
|
239
|
+
|
|
240
|
+
# Returns an option, :quit, :again (decision changed), or nil to keep asking.
|
|
241
|
+
def command(line, decision)
|
|
242
|
+
case line
|
|
243
|
+
when /\A\d+\z/
|
|
244
|
+
option = decision.options[line.to_i - 1]
|
|
245
|
+
option || (say("Choose 1-#{decision.options.size}.") && nil)
|
|
246
|
+
when "b" then show_board(decision) && nil
|
|
247
|
+
when "o"
|
|
248
|
+
@options[:observer] = !@options[:observer]
|
|
249
|
+
say "Observer view #{@options[:observer] ? "on" : "off"}."
|
|
250
|
+
show_board(decision)
|
|
251
|
+
nil
|
|
252
|
+
when /\Ah(?:\s+(\d+))?\z/ then show_history(Integer(::Regexp.last_match(1) || 10)) && nil
|
|
253
|
+
when "u" then undo
|
|
254
|
+
when /\As\s+(\S+)\z/ then save(::Regexp.last_match(1)) && nil
|
|
255
|
+
when "c" then @game.concede(decision.player) && :again
|
|
256
|
+
when "q" then :quit
|
|
257
|
+
when "?", "help" then say(HELP) && nil
|
|
258
|
+
else say("Unknown command. Type ? for help.") && nil
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Priority where passing is the only real choice: a human seat passes without being asked. Mana abilities
|
|
263
|
+
# don't count: with nothing castable or activatable to spend it on, the mana would just empty (CR 500.4).
|
|
264
|
+
def only_pass?(decision)
|
|
265
|
+
decision.kind == "priority" && decision.options.all? { %w[pass activate_mana].include?(it[:type]) }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def undo
|
|
269
|
+
log = @game.decision_log
|
|
270
|
+
if log.empty? || @setup.nil?
|
|
271
|
+
say(@setup ? "Nothing to undo." : "Undo needs a game started in this session.")
|
|
272
|
+
return nil
|
|
273
|
+
end
|
|
274
|
+
@game = @setup.new_game(seed: @game.state.seed).apply(log[0...-1])
|
|
275
|
+
@text = Text.new(@game)
|
|
276
|
+
say "Undid #{@text.option(log.last.transform_keys(&:to_sym))}."
|
|
277
|
+
:again
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def save(path)
|
|
281
|
+
File.write(path, JSON.generate(@game.to_h.merge("cards" => @game.catalog.reject(&:token?).map(&:to_h))))
|
|
282
|
+
say "Saved to #{path}. Resume with: enjine --load #{path}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def show_history(count)
|
|
286
|
+
viewer = @options[:observer] ? :observer : @game.pending_decision&.player || :observer
|
|
287
|
+
@game.history_for(viewer).last(count).each do |entry|
|
|
288
|
+
who = if entry["player"]
|
|
289
|
+
"#{entry["player"]}: #{entry["option"] ? @text.option(entry["option"].transform_keys(&:to_sym)) : entry["kind"]}"
|
|
290
|
+
else
|
|
291
|
+
"(game)"
|
|
292
|
+
end
|
|
293
|
+
say " ##{entry["seq"]} T#{entry["turn"]} #{entry["step"] || "pregame"} — #{who}"
|
|
294
|
+
@text.events(entry["events"]).each { say " #{it}" }
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# An automatic pass doesn't print its own "passes" line; what it caused (a resolution, the next step) does.
|
|
299
|
+
def show_events(entry, auto: false)
|
|
300
|
+
viewer = @options[:observer] || @seats.values.none?(:human) ? :observer : entry.player || :observer
|
|
301
|
+
events = entry.for_viewer(viewer)["events"]
|
|
302
|
+
events = events.reject { it["type"] == "priority_passed" && it["player"] == entry.player } if auto
|
|
303
|
+
lines = @text.events(events)
|
|
304
|
+
lines.each { say " #{it}" }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def say(text)
|
|
308
|
+
@out.puts(text) unless text.nil?
|
|
309
|
+
true
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Decisions
|
|
5
|
+
# CR 103.5: taking a mulligan ends with putting N cards from the new hand on the
|
|
6
|
+
# bottom of the library (asked once per card, so the order is the order chosen),
|
|
7
|
+
# before the next keep/mulligan declaration.
|
|
8
|
+
class Bottom < Base
|
|
9
|
+
def self.doc
|
|
10
|
+
"Right after a mulligan, put one card from the new hand on the bottom of the library: " \
|
|
11
|
+
'`{type: "bottom", object: id}`, once per mulligan taken so far, before declaring again. ' \
|
|
12
|
+
"Hidden from the opponent (CR 103.5)."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.options(engine, pending)
|
|
16
|
+
engine.player(pending["player"]).hand.map { |id| { type: "bottom", object: id } }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.submit(engine, _pending, option)
|
|
20
|
+
engine.move!(option[:object], :library, position: :bottom, reason: "mulligan_bottom")
|
|
21
|
+
engine.next_bottom_or_declaration!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Which card went to the bottom is hidden from the opponent.
|
|
25
|
+
def self.redact(option, viewer_is_decider) = viewer_is_decider ? option : { type: "bottom" }
|
|
26
|
+
end
|
|
27
|
+
register("bottom", Bottom)
|
|
28
|
+
end
|
|
29
|
+
end
|