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,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "snapshot/turn_checks"
|
|
4
|
+
require_relative "snapshot/player_checks"
|
|
5
|
+
|
|
6
|
+
module Enjine
|
|
7
|
+
# Converts State to and from plain data, with complete consistency validation.
|
|
8
|
+
#
|
|
9
|
+
# The validation doubles as the engine's invariant check: the fuzzer runs it
|
|
10
|
+
# after every decision of every random game.
|
|
11
|
+
module Snapshot
|
|
12
|
+
VERSION = 2
|
|
13
|
+
PLAYER_KEYS = %w[name life poison library hand graveyard mana_pool lands_played drew_from_empty lost
|
|
14
|
+
mulligans kept mulligan_choice].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def validate(data, catalog)
|
|
19
|
+
errors = []
|
|
20
|
+
return ["snapshot must be an object"] unless data.is_a?(Hash)
|
|
21
|
+
return ["version: expected #{VERSION}, got #{data["version"].inspect}"] unless data["version"] == VERSION
|
|
22
|
+
|
|
23
|
+
validate_header(data, errors)
|
|
24
|
+
validate_players(data, errors)
|
|
25
|
+
validate_objects(data, catalog, errors)
|
|
26
|
+
validate_combat(data, errors) if errors.empty?
|
|
27
|
+
validate_turn(data, errors) if errors.empty?
|
|
28
|
+
validate_delayed_triggers(data, catalog, errors) if errors.empty?
|
|
29
|
+
validate_extra_turns(data, errors)
|
|
30
|
+
validate_extra_phases(data, errors)
|
|
31
|
+
validate_outside(data, catalog, errors)
|
|
32
|
+
errors
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def load(data, catalog)
|
|
36
|
+
data = deep_stringify(data)
|
|
37
|
+
errors = validate(data, catalog)
|
|
38
|
+
raise InvalidSnapshot, errors unless errors.empty?
|
|
39
|
+
|
|
40
|
+
build(data)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def build(data)
|
|
44
|
+
state = State.new
|
|
45
|
+
state.seed = data["seed"]
|
|
46
|
+
state.rng = Rng.new(data["rng"])
|
|
47
|
+
state.decklists = data["decklists"].map { |l| l.dup.freeze }.freeze
|
|
48
|
+
state.players = data["players"].map { |p| build_player(p) }
|
|
49
|
+
state.objects = data["objects"].to_h do |o|
|
|
50
|
+
[o["id"], build_object(o)]
|
|
51
|
+
end
|
|
52
|
+
State::SHARED_ZONES.each { |z| state.public_send("#{z}=", data.fetch(z.to_s, []).dup) }
|
|
53
|
+
state.attackers = deep_copy(data.fetch("attackers", {}))
|
|
54
|
+
state.blockers = deep_copy(data.fetch("blockers", {}))
|
|
55
|
+
state.effects = deep_copy(data.fetch("effects", []))
|
|
56
|
+
state.frames = deep_copy(data.fetch("frames", []))
|
|
57
|
+
state.pending_triggers = deep_copy(data.fetch("pending_triggers", []))
|
|
58
|
+
state.delayed_triggers = deep_copy(data.fetch("delayed_triggers", []))
|
|
59
|
+
state.extra_turns = deep_copy(data.fetch("extra_turns", { "queue" => [], "begun" => [] }))
|
|
60
|
+
state.extra_phases = deep_copy(data.fetch("extra_phases", State.no_extra_phases))
|
|
61
|
+
state.outside_lists = data.fetch("outside_lists", [[], []]).map { |l| l.dup.freeze }.freeze
|
|
62
|
+
state.repeat_cleanup = data.fetch("repeat_cleanup", false)
|
|
63
|
+
state.combat_damage_pass = data["combat_damage_pass"]
|
|
64
|
+
state.attacks_declared = data.fetch("attacks_declared", 0)
|
|
65
|
+
%w[turn active step_state priority passes next_id starting_player].each do |k|
|
|
66
|
+
state.public_send("#{k}=", data[k])
|
|
67
|
+
end
|
|
68
|
+
state.step = data["step"]&.to_sym
|
|
69
|
+
state.pending = data["pending"]&.dup
|
|
70
|
+
state.result = data["result"]&.dup
|
|
71
|
+
state
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_object(hash)
|
|
75
|
+
GameObject.new(id: hash["id"], card: hash["card"], owner: hash["owner"], controller: hash["controller"],
|
|
76
|
+
zone: hash["zone"].to_sym, tapped: hash["tapped"], controlled_since: hash["controlled_since"],
|
|
77
|
+
kind: hash.fetch("kind", "card"), counters: deep_copy(hash.fetch("counters", {})),
|
|
78
|
+
damage: hash.fetch("damage", 0), source: hash["source"], ability: hash["ability"],
|
|
79
|
+
targets: deep_copy(hash["targets"]), loyalty_turn: hash["loyalty_turn"],
|
|
80
|
+
deathtouched: hash.fetch("deathtouched", false), modes: hash["modes"]&.dup,
|
|
81
|
+
memory: deep_copy(hash.fetch("memory", {})), x: hash["x"], mana_spent: hash["mana_spent"]&.dup,
|
|
82
|
+
attached_to: hash["attached_to"], paid: hash["paid"] && deep_copy(hash["paid"]),
|
|
83
|
+
phased_out: hash["phased_out"]&.dup)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def deep_copy(value) = Marshal.load(Marshal.dump(value))
|
|
87
|
+
|
|
88
|
+
def build_player(hash)
|
|
89
|
+
PlayerState.new(**PLAYER_KEYS.to_h { |k| [k.to_sym, hash[k]] }).tap do |pl|
|
|
90
|
+
pl.library = pl.library.dup
|
|
91
|
+
pl.hand = pl.hand.dup
|
|
92
|
+
pl.graveyard = pl.graveyard.dup
|
|
93
|
+
pl.mana_pool = Mana::TYPES.to_h { |t| [t, hash["mana_pool"].fetch(t.to_s, 0)] }
|
|
94
|
+
pl.turn_log = deep_copy(hash.fetch("turn_log", {}))
|
|
95
|
+
pl.game_log = deep_copy(hash.fetch("game_log", {}))
|
|
96
|
+
pl.last_turn_log = deep_copy(hash.fetch("last_turn_log", {}))
|
|
97
|
+
pl.restricted_mana = deep_copy(hash.fetch("restricted_mana", []))
|
|
98
|
+
pl.outside = hash.fetch("outside", []).dup
|
|
99
|
+
pl.speed = hash["speed"] # CR 702.179; absent: no speed
|
|
100
|
+
pl.designations = hash.fetch("designations", []).dup # CR 702.195b (Engine::Designations)
|
|
101
|
+
pl.counters = hash.fetch("counters", {}).dup # CR 122.1: counters other than poison
|
|
102
|
+
pl.pool_sources = deep_copy(hash.fetch("pool_sources", [])) # Engine::ManaSources#add_mana!
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_header(data, errors)
|
|
107
|
+
errors << "rng: must be a non-negative integer" unless non_negative?(data["rng"])
|
|
108
|
+
errors << "next_id: must be a positive integer" unless data["next_id"].is_a?(Integer) && data["next_id"].positive?
|
|
109
|
+
errors << "decklists: must be a list of card id lists" unless data["decklists"].is_a?(Array) && data["decklists"].all?(Array)
|
|
110
|
+
%w[battlefield stack exile].each { |z| errors << "#{z}: must be a list" unless data[z].is_a?(Array) }
|
|
111
|
+
errors << "phased_out: must be a list" unless data.fetch("phased_out", []).is_a?(Array)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate_players(data, errors)
|
|
115
|
+
players = data["players"]
|
|
116
|
+
return errors << "players: must be a list of 2 players" unless players.is_a?(Array) && players.size == 2
|
|
117
|
+
|
|
118
|
+
players.each_with_index do |pl, i|
|
|
119
|
+
path = "players[#{i}]"
|
|
120
|
+
next errors << "#{path}: must be an object" unless pl.is_a?(Hash)
|
|
121
|
+
|
|
122
|
+
(PLAYER_KEYS - pl.keys).each { |k| errors << "#{path}.#{k}: missing" }
|
|
123
|
+
%w[life poison lands_played mulligans].each do |k|
|
|
124
|
+
errors << "#{path}.#{k}: must be an integer" unless pl[k].is_a?(Integer)
|
|
125
|
+
end
|
|
126
|
+
%w[library hand graveyard].each { |z| errors << "#{path}.#{z}: must be a list" unless pl[z].is_a?(Array) }
|
|
127
|
+
pool = pl["mana_pool"]
|
|
128
|
+
unless pool.is_a?(Hash) && pool.all? { |t, n| Mana::TYPES.include?(t.to_sym) && non_negative?(n) }
|
|
129
|
+
errors << "#{path}.mana_pool: must map W/U/B/R/G/C to non-negative integers"
|
|
130
|
+
end
|
|
131
|
+
validate_restricted_mana(pl.fetch("restricted_mana", []), errors, "#{path}.restricted_mana")
|
|
132
|
+
validate_pool_sources(pl, errors, "#{path}.pool_sources")
|
|
133
|
+
errors << "#{path}.speed: must be 1, 2, 3 or 4 (or absent)" unless [nil, 1, 2, 3, 4].include?(pl["speed"]) # CR 702.179
|
|
134
|
+
validate_designations(pl.fetch("designations", []), errors, "#{path}.designations")
|
|
135
|
+
end
|
|
136
|
+
validate_logs(players, errors)
|
|
137
|
+
names = players.map { it.is_a?(Hash) ? it["name"] : nil }
|
|
138
|
+
errors << "players: names must be distinct non-empty strings" unless names.all?(String) && names.uniq.size == 2
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# What happened this turn and this game (Engine::TurnHistory) and counters on the players; absent in older
|
|
142
|
+
# snapshots.
|
|
143
|
+
def validate_logs(players, errors)
|
|
144
|
+
players.each_with_index.select { |pl, _| pl.is_a?(Hash) }.each do |pl, i|
|
|
145
|
+
%w[turn_log game_log last_turn_log].each do |k|
|
|
146
|
+
errors << "players[#{i}].#{k}: must be an object" unless pl.fetch(k, {}).is_a?(Hash)
|
|
147
|
+
end
|
|
148
|
+
counters = pl.fetch("counters", {}) # CR 122.1: counters other than poison; absent in older snapshots
|
|
149
|
+
next if counters.is_a?(Hash) && counters.all? { |k, n| Mechanics::Counters.valid?(k) && k != "poison" && non_negative?(n) }
|
|
150
|
+
|
|
151
|
+
errors << "players[#{i}].counters: must map counter kinds other than poison to non-negative integers"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def validate_objects(data, catalog, errors)
|
|
156
|
+
objects = data["objects"]
|
|
157
|
+
return errors << "objects: must be a list" unless objects.is_a?(Array)
|
|
158
|
+
return unless errors.empty?
|
|
159
|
+
|
|
160
|
+
seen = {}
|
|
161
|
+
objects.each_with_index do |o, i|
|
|
162
|
+
path = "objects[#{i}]"
|
|
163
|
+
next errors << "#{path}: must be an object" unless o.is_a?(Hash)
|
|
164
|
+
|
|
165
|
+
id = o["id"]
|
|
166
|
+
errors << "#{path}: duplicate id #{id.inspect}" if seen.key?(id)
|
|
167
|
+
seen[id] = o
|
|
168
|
+
validate_object(o, path, catalog, data["next_id"], errors)
|
|
169
|
+
end
|
|
170
|
+
validate_zone_membership(data, seen, errors)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def validate_object(obj, path, catalog, next_id, errors)
|
|
174
|
+
validate_object_id(obj, path, catalog, next_id, errors)
|
|
175
|
+
%w[owner controller].each { |k| errors << "#{path}.#{k}: must be 0 or 1" unless [0, 1].include?(obj[k]) }
|
|
176
|
+
zone = obj["zone"].to_s
|
|
177
|
+
errors << "#{path}.zone: unknown zone #{zone.inspect}" unless State::ZONES.map(&:to_s).include?(zone)
|
|
178
|
+
errors << "#{path}.tapped: must be true or false" unless [true, false].include?(obj["tapped"])
|
|
179
|
+
validate_object_state(obj, path, zone, errors)
|
|
180
|
+
validate_phasing(obj, path, zone, errors)
|
|
181
|
+
return if %w[battlefield stack phased_out].include?(zone)
|
|
182
|
+
|
|
183
|
+
errors << "#{path}: only permanents on the battlefield can be tapped" if obj["tapped"]
|
|
184
|
+
errors << "#{path}: controller must equal owner outside the battlefield and stack" if obj["controller"] != obj["owner"]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def validate_object_state(obj, path, zone, errors)
|
|
188
|
+
kind = obj.fetch("kind", "card")
|
|
189
|
+
errors << "#{path}.kind: must be \"card\", \"ability\" or \"emblem\"" unless %w[card ability emblem].include?(kind)
|
|
190
|
+
home = { "ability" => "stack", "emblem" => "command" }[kind]
|
|
191
|
+
if home
|
|
192
|
+
errors << "#{path}: #{kind}s can only exist in the #{home} zone" unless zone == home
|
|
193
|
+
errors << "#{path}.ability: must be an ability reference" unless obj["ability"].is_a?(String)
|
|
194
|
+
end
|
|
195
|
+
errors << "#{path}: only emblems are in the command zone (CR 114.1)" if zone == "command" && kind != "emblem"
|
|
196
|
+
counters = obj.fetch("counters", {})
|
|
197
|
+
unless counters.is_a?(Hash) && counters.all? { |k, v| Mechanics::Counters.valid?(k) && non_negative?(v) }
|
|
198
|
+
errors << "#{path}.counters: must map counter kinds to non-negative integers"
|
|
199
|
+
end
|
|
200
|
+
errors << "#{path}.damage: must be a non-negative integer" unless non_negative?(obj.fetch("damage", 0))
|
|
201
|
+
errors << "#{path}.memory: must be an object" unless obj.fetch("memory", {}).is_a?(Hash)
|
|
202
|
+
validate_cast_info(obj, path, errors)
|
|
203
|
+
validate_attachment(obj, path, zone, errors)
|
|
204
|
+
return if ON_BATTLEFIELD.include?(zone)
|
|
205
|
+
|
|
206
|
+
# Cards in exile may have counters (time counters of suspend, CR 702.62a); only permanents have damage.
|
|
207
|
+
return unless (zone != "exile" && counters.values.any?(&:positive?)) || obj["damage"].to_i.positive?
|
|
208
|
+
|
|
209
|
+
errors << "#{path}: only permanents have counters or damage"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The X announced for a spell or ability and the mana spent on it (CR 107.3a, 702.44a).
|
|
213
|
+
def validate_cast_info(obj, path, errors)
|
|
214
|
+
errors << "#{path}.x: must be a non-negative integer" unless obj["x"].nil? || non_negative?(obj["x"])
|
|
215
|
+
spent = obj["mana_spent"]
|
|
216
|
+
return if spent.nil? || (spent.is_a?(Array) && spent.all? { Mana::TYPES.map(&:to_s).include?(it) })
|
|
217
|
+
|
|
218
|
+
errors << "#{path}.mana_spent: must be a list of mana types"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# CR 701.3: only a permanent is attached, never to itself. The object it names may already
|
|
222
|
+
# be gone: state-based actions (704.5m/n) clean that up before the next priority.
|
|
223
|
+
def validate_attachment(obj, path, zone, errors)
|
|
224
|
+
to = obj["attached_to"]
|
|
225
|
+
return if to.nil?
|
|
226
|
+
|
|
227
|
+
errors << "#{path}.attached_to: must be another object's id" unless to.is_a?(String) && to != obj["id"]
|
|
228
|
+
errors << "#{path}: only permanents on the battlefield can be attached" unless ON_BATTLEFIELD.include?(zone)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Phased out (CR 702.26): still a permanent on the battlefield, kept in the `phased_out` list with who
|
|
232
|
+
# controlled it as it phased out and whether it phased out indirectly.
|
|
233
|
+
ON_BATTLEFIELD = %w[battlefield phased_out].freeze
|
|
234
|
+
|
|
235
|
+
def validate_phasing(obj, path, zone, errors)
|
|
236
|
+
info = obj["phased_out"]
|
|
237
|
+
return errors << "#{path}.phased_out: only phased-out permanents have it" if info && zone != "phased_out"
|
|
238
|
+
return if zone != "phased_out"
|
|
239
|
+
return if info.is_a?(Hash) && [0, 1].include?(info["by"]) && [true, false].include?(info["indirectly"])
|
|
240
|
+
|
|
241
|
+
errors << "#{path}.phased_out: must be {\"by\": 0 or 1, \"indirectly\": true or false}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def validate_combat(data, errors)
|
|
245
|
+
battlefield = data["battlefield"]
|
|
246
|
+
attackers = data.fetch("attackers", {})
|
|
247
|
+
blockers = data.fetch("blockers", {})
|
|
248
|
+
return errors << "attackers/blockers: must be objects" unless attackers.is_a?(Hash) && blockers.is_a?(Hash)
|
|
249
|
+
|
|
250
|
+
attackers.each_key { |id| errors << "attackers: #{id} is not on the battlefield" unless battlefield.include?(id) }
|
|
251
|
+
blockers.each do |id, attacker|
|
|
252
|
+
errors << "blockers: #{id} is not on the battlefield" unless battlefield.include?(id)
|
|
253
|
+
errors << "blockers: #{id} blocks #{attacker}, which is not attacking" unless attackers.key?(attacker)
|
|
254
|
+
end
|
|
255
|
+
errors << "effects: must be a list" unless data.fetch("effects", []).is_a?(Array)
|
|
256
|
+
validate_frames(data, errors)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def validate_frames(data, errors)
|
|
260
|
+
frames = data.fetch("frames", [])
|
|
261
|
+
return errors << "frames: must be a list" unless frames.is_a?(Array)
|
|
262
|
+
|
|
263
|
+
frames.each_with_index do |frame, i|
|
|
264
|
+
klass = frame.is_a?(Hash) && Frames::KINDS[frame["type"]]
|
|
265
|
+
next errors << "frames[#{i}]: unknown frame #{frame.inspect[0, 60]}" unless klass
|
|
266
|
+
|
|
267
|
+
klass.validate(frame, data, errors)
|
|
268
|
+
end
|
|
269
|
+
top = frames.last
|
|
270
|
+
kind = data.dig("pending", "kind")
|
|
271
|
+
return unless top && kind && !Frames.fetch(top["type"]).decisions.include?(kind)
|
|
272
|
+
|
|
273
|
+
errors << "pending: #{kind} can't be pending while a #{top["type"]} frame is in progress"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def validate_zone_membership(data, objects, errors)
|
|
277
|
+
placed = Hash.new(0)
|
|
278
|
+
lists = State::SHARED_ZONES.map { |z| [z.to_s, nil, data.fetch(z.to_s, [])] }
|
|
279
|
+
data["players"].each_with_index do |pl, i|
|
|
280
|
+
State::PLAYER_ZONES.each { |z| lists << [z.to_s, i, pl[z.to_s]] }
|
|
281
|
+
end
|
|
282
|
+
lists.each do |zone, owner, ids|
|
|
283
|
+
ids.each do |id|
|
|
284
|
+
placed[id] += 1
|
|
285
|
+
obj = objects[id]
|
|
286
|
+
next errors << "#{zone}: unknown object #{id.inspect}" unless obj
|
|
287
|
+
next errors << "#{id}: listed in #{zone} but its zone is #{obj["zone"]}" unless obj["zone"].to_s == zone
|
|
288
|
+
next unless owner && obj["owner"] != owner
|
|
289
|
+
|
|
290
|
+
errors << "#{id}: in #{data["players"][owner]["name"]}'s #{zone} but owned by player #{obj["owner"]}"
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
objects.each_key { |id| errors << "#{id}: in #{placed[id]} zones, expected exactly 1" if placed[id] != 1 }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def non_negative?(value) = value.is_a?(Integer) && !value.negative?
|
|
297
|
+
|
|
298
|
+
def deep_stringify(value)
|
|
299
|
+
case value
|
|
300
|
+
when Hash then value.to_h { |k, v| [k.to_s, deep_stringify(v)] }
|
|
301
|
+
when Array then value.map { deep_stringify(it) }
|
|
302
|
+
when Symbol then value.to_s
|
|
303
|
+
else value
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
data/lib/enjine/state.rb
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# A card or token in a zone (CR 109). Refers to its definition by id only.
|
|
5
|
+
# A new GameObject (with a new id) is created on every zone change (CR 400.7).
|
|
6
|
+
GameObject = Struct.new(:id, :card, :owner, :controller, :zone, :tapped, :controlled_since, :kind, :counters,
|
|
7
|
+
:damage, :source, :ability, :targets, :loyalty_turn, :deathtouched, :modes, :memory,
|
|
8
|
+
:x, :mana_spent, :attached_to,
|
|
9
|
+
:paid, # paid: choices made paying costs, e.g. the creature tapped (CR 702.184a)
|
|
10
|
+
:phased_out) do # {"by" => controller, "indirectly" => bool} while phased out (CR 702.26)
|
|
11
|
+
def ability? = kind == "ability"
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
{ "id" => id, "card" => card, "owner" => owner, "controller" => controller, "zone" => zone.to_s,
|
|
15
|
+
"tapped" => tapped, "controlled_since" => controlled_since, "kind" => kind, "counters" => counters.dup,
|
|
16
|
+
"damage" => damage, "source" => source, "ability" => ability, "targets" => targets&.dup,
|
|
17
|
+
"loyalty_turn" => loyalty_turn, "deathtouched" => deathtouched,
|
|
18
|
+
"modes" => modes&.dup, "memory" => Marshal.load(Marshal.dump(memory || {})), "x" => x,
|
|
19
|
+
"mana_spent" => mana_spent&.dup, "attached_to" => attached_to, "paid" => paid&.map(&:dup),
|
|
20
|
+
"phased_out" => phased_out&.dup }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Per-player game state.
|
|
25
|
+
PlayerState = Struct.new(:name, :life, :poison, :library, :hand, :graveyard, :mana_pool, :lands_played,
|
|
26
|
+
:drew_from_empty, :lost, :mulligans, :kept, :mulligan_choice,
|
|
27
|
+
:turn_log, :game_log, # what happened this turn / this game (see Engine::History)
|
|
28
|
+
:last_turn_log, # what happened during the previous turn (CR 500.1)
|
|
29
|
+
:restricted_mana, # mana with a spending restriction (CR 106.6), see Engine#add_mana!
|
|
30
|
+
:outside, # definition ids of the cards they own outside the game (CR 400.11a)
|
|
31
|
+
:speed, # nil (no speed) or 1-4 (CR 702.179), see Engine::Speed
|
|
32
|
+
:designations, # player designations, e.g. "enduring_story" (CR 702.195b), see Engine::Designations
|
|
33
|
+
:counters, # nil or {kind => n}: counters other than poison (CR 122.1), see Engine#add_player_counters!
|
|
34
|
+
:pool_sources) do # nil or [{"mana", "from"}]: sources of pool mana made by artifacts (Engine#add_mana!)
|
|
35
|
+
def zone(name)
|
|
36
|
+
case name
|
|
37
|
+
when :library then library
|
|
38
|
+
when :hand then hand
|
|
39
|
+
when :graveyard then graveyard
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_h
|
|
44
|
+
{ "name" => name, "life" => life, "poison" => poison, "library" => library.dup, "hand" => hand.dup,
|
|
45
|
+
"graveyard" => graveyard.dup, "mana_pool" => mana_pool.transform_keys(&:to_s),
|
|
46
|
+
"lands_played" => lands_played, "drew_from_empty" => drew_from_empty, "lost" => lost,
|
|
47
|
+
"mulligans" => mulligans, "kept" => kept, "mulligan_choice" => mulligan_choice,
|
|
48
|
+
"turn_log" => deep(turn_log || {}), "game_log" => deep(game_log || {}), "last_turn_log" => deep(last_turn_log || {}),
|
|
49
|
+
"restricted_mana" => deep(restricted_mana || []), "outside" => (outside || []).dup }.merge(optional_h)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def deep(value) = Marshal.load(Marshal.dump(value))
|
|
55
|
+
|
|
56
|
+
# Fields left out while empty.
|
|
57
|
+
def optional_h
|
|
58
|
+
{ "speed" => speed, "designations" => designations.to_a.empty? ? nil : designations.dup,
|
|
59
|
+
"counters" => counters.nil? || counters.empty? ? nil : counters.dup,
|
|
60
|
+
"pool_sources" => pool_sources.nil? || pool_sources.empty? ? nil : deep(pool_sources) }.compact
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The complete, mutable state of one game. Plain data only: everything here
|
|
65
|
+
# round-trips through #to_h / Snapshot.load, and Marshal copies it cheaply.
|
|
66
|
+
class State
|
|
67
|
+
STARTING_LIFE = 20
|
|
68
|
+
PLAYER_ZONES = %i[library hand graveyard].freeze
|
|
69
|
+
# `phased_out` is not a zone of the rules: phased-out permanents are on the battlefield but treated as
|
|
70
|
+
# though they don't exist (CR 702.26b, 702.26d), so they are kept apart from `battlefield`. The command
|
|
71
|
+
# zone holds emblems (CR 114, 408).
|
|
72
|
+
SHARED_ZONES = %i[battlefield stack exile phased_out command].freeze
|
|
73
|
+
ZONES = (PLAYER_ZONES + SHARED_ZONES).freeze
|
|
74
|
+
STEP_STATES = %w[begin active done].freeze
|
|
75
|
+
|
|
76
|
+
attr_accessor :seed, :rng, :decklists, :players, :objects, :battlefield, :stack, :exile, :phased_out, :command, :turn,
|
|
77
|
+
:active,
|
|
78
|
+
:step, :step_state, :priority, :passes, :next_id, :starting_player, :pending, :result,
|
|
79
|
+
:attackers, :blockers, :effects, :frames, :attacks_declared,
|
|
80
|
+
:pending_triggers, :repeat_cleanup, :combat_damage_pass, :delayed_triggers,
|
|
81
|
+
:extra_turns, :extra_phases
|
|
82
|
+
# The definition ids each player started the game with outside it (CR 400.11a, 100.4), like
|
|
83
|
+
# `decklists`: a restart begins from these again. What is still outside is PlayerState#outside.
|
|
84
|
+
attr_accessor :outside_lists
|
|
85
|
+
|
|
86
|
+
def self.initial(names:, decklists:, seed:, outside: nil)
|
|
87
|
+
state = new
|
|
88
|
+
state.seed = seed
|
|
89
|
+
state.rng = Rng.from_seed(seed)
|
|
90
|
+
state.decklists = decklists.map { |l| l.dup.freeze }.freeze
|
|
91
|
+
state.outside_lists = (outside || names.map { [] }).map { |l| l.dup.freeze }.freeze
|
|
92
|
+
state.players = names.each_with_index.map { |name, i| new_player(name, state.outside_lists[i]) }
|
|
93
|
+
state.objects = {}
|
|
94
|
+
state.battlefield = []
|
|
95
|
+
state.stack = []
|
|
96
|
+
state.exile = []
|
|
97
|
+
state.phased_out = []
|
|
98
|
+
state.command = []
|
|
99
|
+
state.attackers = {}
|
|
100
|
+
state.blockers = {}
|
|
101
|
+
state.effects = []
|
|
102
|
+
state.frames = []
|
|
103
|
+
state.pending_triggers = []
|
|
104
|
+
state.delayed_triggers = []
|
|
105
|
+
state.extra_turns = { "queue" => [], "begun" => [] } # CR 500.7
|
|
106
|
+
state.extra_phases = no_extra_phases # CR 500.8
|
|
107
|
+
state.repeat_cleanup = false
|
|
108
|
+
state.combat_damage_pass = nil
|
|
109
|
+
state.attacks_declared = 0
|
|
110
|
+
state.turn = 0
|
|
111
|
+
state.passes = 0
|
|
112
|
+
state.next_id = 1
|
|
113
|
+
state.step_state = "begin"
|
|
114
|
+
state
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Phases added this turn (CR 500.8): "pending" [{"after": phase, "phases": ["combat", "main"]}] wait for
|
|
118
|
+
# their phase to end, "queue" lists added phases still to come and "resume" the step the turn goes on
|
|
119
|
+
# with after them (Engine::TurnStructure); "combats" counts combat phases begun this turn.
|
|
120
|
+
def self.no_extra_phases = { "pending" => [], "queue" => [], "combats" => 0 }
|
|
121
|
+
|
|
122
|
+
def self.new_player(name, outside = [])
|
|
123
|
+
PlayerState.new(name: name, life: STARTING_LIFE, poison: 0, library: [], hand: [], graveyard: [],
|
|
124
|
+
mana_pool: Mana.empty_pool, lands_played: 0, drew_from_empty: false, lost: nil,
|
|
125
|
+
mulligans: 0, kept: false, mulligan_choice: nil, turn_log: {}, game_log: {}, last_turn_log: {}, restricted_mana: [],
|
|
126
|
+
outside: outside.dup)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def zone_list(zone, owner)
|
|
130
|
+
SHARED_ZONES.include?(zone) ? public_send(zone) : players[owner].zone(zone)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def object(id) = objects.fetch(id)
|
|
134
|
+
def opponent(index) = 1 - index
|
|
135
|
+
def over? = !result.nil?
|
|
136
|
+
|
|
137
|
+
def to_h
|
|
138
|
+
{ "version" => Snapshot::VERSION, "rules" => RULES_VERSION, "seed" => seed, "rng" => rng.state,
|
|
139
|
+
"decklists" => decklists.map(&:dup), "turn" => turn, "active" => active, "step" => step&.to_s,
|
|
140
|
+
"step_state" => step_state, "priority" => priority, "passes" => passes, "next_id" => next_id,
|
|
141
|
+
"starting_player" => starting_player, "pending" => pending&.dup, "result" => result&.dup,
|
|
142
|
+
"players" => players.map(&:to_h), "objects" => objects.values.map(&:to_h),
|
|
143
|
+
"battlefield" => battlefield.dup, "stack" => stack.dup, "exile" => exile.dup, "phased_out" => phased_out.dup,
|
|
144
|
+
"command" => (command || []).dup,
|
|
145
|
+
"attackers" => attackers.dup, "blockers" => blockers.dup, "effects" => effects.map { deep_dup(it) },
|
|
146
|
+
**DEEP_FIELDS.to_h { [it, deep_dup(public_send(it))] },
|
|
147
|
+
"repeat_cleanup" => repeat_cleanup, "combat_damage_pass" => combat_damage_pass,
|
|
148
|
+
"attacks_declared" => attacks_declared }.merge("outside_lists" => (outside_lists || []).map(&:dup))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Nested fields #to_h deep-copies as they are.
|
|
152
|
+
DEEP_FIELDS = %w[frames pending_triggers delayed_triggers extra_turns extra_phases].freeze
|
|
153
|
+
|
|
154
|
+
# An independent deep copy, about 10x faster than a Marshal round trip. It walks every
|
|
155
|
+
# instance variable, so fields added later are copied automatically. Strings, symbols,
|
|
156
|
+
# numbers and frozen values are shared: state never mutates them in place.
|
|
157
|
+
def fork
|
|
158
|
+
copy = State.allocate
|
|
159
|
+
instance_variables.each { |ivar| copy.instance_variable_set(ivar, deep_dup(instance_variable_get(ivar))) }
|
|
160
|
+
copy
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def deep_dup(value)
|
|
164
|
+
case value
|
|
165
|
+
when Hash then value.frozen? ? value : value.to_h { |k, v| [k, deep_dup(v)] }
|
|
166
|
+
when Array then value.frozen? ? value : value.map { deep_dup(it) }
|
|
167
|
+
when Struct then value.dup.tap { |copy| copy.each_pair { |k, v| copy[k] = deep_dup(v) } }
|
|
168
|
+
when Rng then Rng.new(value.state)
|
|
169
|
+
else value
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
data/lib/enjine/steps.rb
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# The twelve steps of a turn (CR 500.1). Each step object owns its turn-based
|
|
5
|
+
# actions and whether players receive priority during it.
|
|
6
|
+
module Steps
|
|
7
|
+
# Base step: turn-based actions are a no-op and players get priority.
|
|
8
|
+
class Step
|
|
9
|
+
attr_reader :name, :phase
|
|
10
|
+
|
|
11
|
+
def initialize(name, phase)
|
|
12
|
+
@name = name
|
|
13
|
+
@phase = phase
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def priority? = true
|
|
18
|
+
def skip?(_engine) = false
|
|
19
|
+
def finish(_engine) = nil
|
|
20
|
+
|
|
21
|
+
# Performs the step's turn-based actions. May set a pending decision; in that
|
|
22
|
+
# case the decision calls engine.turn_based_actions_done! when finished.
|
|
23
|
+
def begin(engine)
|
|
24
|
+
engine.turn_based_actions_done!
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# CR 502: phasing (502.1), then the active player's permanents untap (502.3), some by their choice
|
|
29
|
+
# (Engine::TurnStructure); no priority (502.4).
|
|
30
|
+
class Untap < Step
|
|
31
|
+
def priority? = false
|
|
32
|
+
def begin(engine) = engine.begin_untap_step!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# CR 504: the active player draws. In a two-player game the starting player skips the whole
|
|
36
|
+
# draw step of their first turn (CR 103.8a): no draw, no priority, no "draw step" triggers.
|
|
37
|
+
class Draw < Step
|
|
38
|
+
def skip?(engine) = engine.state.then { it.turn == 1 && it.active == it.starting_player }
|
|
39
|
+
|
|
40
|
+
def begin(engine)
|
|
41
|
+
engine.draw!(engine.state.active)
|
|
42
|
+
engine.turn_based_actions_done!
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# CR 505.4 / 715.3c: as the precombat main phase begins, the active player puts a
|
|
47
|
+
# lore counter on each Saga they control with one or more chapter abilities.
|
|
48
|
+
class PrecombatMain < Step
|
|
49
|
+
def begin(engine)
|
|
50
|
+
engine.add_lore_counters!(engine.state.active)
|
|
51
|
+
engine.turn_based_actions_done!
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# CR 508.8: if no creatures were declared as attackers, declare blockers and combat damage are skipped.
|
|
56
|
+
class CombatStepAfterAttackers < Step
|
|
57
|
+
def skip?(engine) = engine.state.attacks_declared.zero?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# CR 508.1: the active player declares attackers (a turn-based action).
|
|
61
|
+
class DeclareAttackers < Step
|
|
62
|
+
def begin(engine) = engine.begin_declare_attackers!
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# CR 509.1: the defending player declares blockers.
|
|
66
|
+
class DeclareBlockers < CombatStepAfterAttackers
|
|
67
|
+
def begin(engine) = engine.begin_declare_blockers!
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# CR 510: combat damage is assigned and dealt simultaneously.
|
|
71
|
+
class CombatDamage < CombatStepAfterAttackers
|
|
72
|
+
def begin(engine) = engine.begin_combat_damage!
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# CR 514: discard to maximum hand size, then end "until end of turn" effects; no priority.
|
|
76
|
+
class Cleanup < Step
|
|
77
|
+
def priority? = false
|
|
78
|
+
|
|
79
|
+
# CR 514.2: remove marked damage and end "until end of turn" effects (control returns first).
|
|
80
|
+
def finish(engine)
|
|
81
|
+
engine.revert_temporary_control!
|
|
82
|
+
engine.cleanup_turn!
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# CR 514.1 / 402.2: discard down to the maximum hand size, if the player has one.
|
|
86
|
+
def begin(engine)
|
|
87
|
+
max = engine.max_hand_size(engine.state.active)
|
|
88
|
+
excess = max ? engine.active_player.hand.size - max : 0
|
|
89
|
+
if excess.positive?
|
|
90
|
+
engine.ask!("kind" => "discard", "player" => engine.state.active, "count" => excess)
|
|
91
|
+
else
|
|
92
|
+
engine.turn_based_actions_done!
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
ORDER = [
|
|
98
|
+
Untap.new(:untap, :beginning),
|
|
99
|
+
Step.new(:upkeep, :beginning),
|
|
100
|
+
Draw.new(:draw, :beginning),
|
|
101
|
+
PrecombatMain.new(:precombat_main, :precombat_main),
|
|
102
|
+
Step.new(:beginning_of_combat, :combat),
|
|
103
|
+
DeclareAttackers.new(:declare_attackers, :combat),
|
|
104
|
+
DeclareBlockers.new(:declare_blockers, :combat),
|
|
105
|
+
CombatDamage.new(:combat_damage, :combat),
|
|
106
|
+
Step.new(:end_of_combat, :combat),
|
|
107
|
+
Step.new(:postcombat_main, :postcombat_main),
|
|
108
|
+
Step.new(:end, :ending),
|
|
109
|
+
Cleanup.new(:cleanup, :ending)
|
|
110
|
+
].freeze
|
|
111
|
+
BY_NAME = ORDER.to_h { [it.name, it] }.freeze
|
|
112
|
+
MAIN_PHASE_STEPS = %i[precombat_main postcombat_main].freeze
|
|
113
|
+
|
|
114
|
+
def self.fetch(name) = BY_NAME.fetch(name.to_sym)
|
|
115
|
+
def self.index(name) = ORDER.index(fetch(name))
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# Predefined tokens (CR 111.10): referenced by name from card data, e.g.
|
|
5
|
+
# {"create_token": {"token": "Treasure"}}. Other tokens are defined inline.
|
|
6
|
+
module Tokens
|
|
7
|
+
ANY_COLOR = %w[{W} {U} {B} {R} {G}].freeze
|
|
8
|
+
|
|
9
|
+
LIBRARY = {
|
|
10
|
+
# CR 111.10a: "{T}, Sacrifice this token: Add one mana of any color."
|
|
11
|
+
"Treasure" => { "name" => "Treasure", "types" => ["artifact"], "subtypes" => ["treasure"],
|
|
12
|
+
"abilities" => ANY_COLOR.map { { "mana" => { "cost" => %w[tap sacrifice], "produce" => it } } } },
|
|
13
|
+
# CR 111.10f: "{2}, Sacrifice this token: Draw a card."
|
|
14
|
+
"Clue" => { "name" => "Clue", "types" => ["artifact"], "subtypes" => ["clue"],
|
|
15
|
+
"abilities" => [{ "activated" => { "cost" => [{ "mana" => "{2}" }, "sacrifice"],
|
|
16
|
+
"effects" => [{ "draw" => { "amount" => 1 } }] } }] },
|
|
17
|
+
# CR 111.10b: "{2}, {T}, Sacrifice this token: You gain 3 life."
|
|
18
|
+
"Food" => { "name" => "Food", "types" => ["artifact"], "subtypes" => ["food"],
|
|
19
|
+
"abilities" => [{ "activated" => { "cost" => [{ "mana" => "{2}" }, "tap", "sacrifice"],
|
|
20
|
+
"effects" => [{ "gain_life" => { "amount" => 3 } }] } }] }
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def self.fetch(name) = LIBRARY[name]
|
|
24
|
+
end
|
|
25
|
+
end
|