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,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# Namespace of every registered primitive plus helpers to build them from card data.
|
|
5
|
+
module Mechanics
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Builds a primitive from a data entry. An entry is either a bare name ("tap")
|
|
9
|
+
# or a single-key Hash ({"mana" => {...params}}).
|
|
10
|
+
def build_entry(category, entry, errors, path, **context)
|
|
11
|
+
name, params = split_entry(entry)
|
|
12
|
+
unless name
|
|
13
|
+
errors << "#{path}: must be a name or a single-key object, got #{entry.inspect}"
|
|
14
|
+
return nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
klass = Enjine.registry.fetch(category, name)
|
|
18
|
+
unless klass
|
|
19
|
+
errors << "#{path}: unknown #{category} #{name.inspect} (supported: #{Enjine.registry.names(category).join(", ")})"
|
|
20
|
+
return nil
|
|
21
|
+
end
|
|
22
|
+
unless params.is_a?(Hash) || klass.respond_to?(:scalar_params?)
|
|
23
|
+
errors << "#{path}.#{name}: parameters must be an object"
|
|
24
|
+
return nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
klass.build(params, errors, "#{path}.#{name}", **context)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def split_entry(entry)
|
|
31
|
+
case entry
|
|
32
|
+
when String, Symbol then [entry.to_s, {}]
|
|
33
|
+
when Hash then entry.size == 1 ? [entry.keys.first.to_s, entry.values.first] : nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Reports parameter keys a primitive does not declare.
|
|
38
|
+
def check_keys(params, allowed, errors, path)
|
|
39
|
+
(params.keys.map(&:to_s) - allowed.map(&:to_s)).each do |key|
|
|
40
|
+
errors << "#{path}: unknown parameter #{key.inspect}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch_param(params, key)
|
|
45
|
+
params.key?(key.to_s) ? params[key.to_s] : params[key.to_sym]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def integer_param(params, key, errors, path, default: nil, min: nil)
|
|
49
|
+
value = fetch_param(params, key)
|
|
50
|
+
value = default if value.nil?
|
|
51
|
+
unless value.is_a?(Integer) && (min.nil? || value >= min)
|
|
52
|
+
errors << "#{path}.#{key}: must be an integer#{" >= #{min}" if min}"
|
|
53
|
+
return default || 0
|
|
54
|
+
end
|
|
55
|
+
value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# An optional true/false parameter; absent is false.
|
|
59
|
+
def flag_param(params, key, errors, path)
|
|
60
|
+
value = fetch_param(params, key)
|
|
61
|
+
errors << "#{path}.#{key}: must be true or false" unless [nil, true, false].include?(value)
|
|
62
|
+
value == true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def list_param(params, key, errors, path)
|
|
66
|
+
value = fetch_param(params, key)
|
|
67
|
+
return [] if value.nil?
|
|
68
|
+
return value if value.is_a?(Array)
|
|
69
|
+
|
|
70
|
+
errors << "#{path}.#{key}: must be a list"
|
|
71
|
+
[]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# A named player with a deck. The player is #ready? when the deck is valid.
|
|
5
|
+
# While seated in an unfinished game the deck cannot be edited (DeckLocked).
|
|
6
|
+
class Player
|
|
7
|
+
attr_reader :name, :deck
|
|
8
|
+
|
|
9
|
+
def initialize(name:, deck:)
|
|
10
|
+
raise ArgumentError, "player name must be a non-empty string" unless name.is_a?(String) && !name.empty?
|
|
11
|
+
|
|
12
|
+
@name = name.dup.freeze
|
|
13
|
+
@deck = Deck.from_h(deck)
|
|
14
|
+
@games = ObjectSpace::WeakMap.new
|
|
15
|
+
@deck.guard = lambda {
|
|
16
|
+
raise DeckLocked, "#{@name} is seated in an unfinished game; finish or concede it first" if playing?
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def ready? = @deck.valid?
|
|
21
|
+
|
|
22
|
+
# True while any game this player is seated in has not ended.
|
|
23
|
+
def playing? = @games.keys.any? { |game| !game.over? }
|
|
24
|
+
|
|
25
|
+
# @api private
|
|
26
|
+
def seat(game)
|
|
27
|
+
@games[game] = true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# Base class of every mechanic the engine understands.
|
|
5
|
+
#
|
|
6
|
+
# A primitive is a named building block that card data can reference:
|
|
7
|
+
# a card type, supertype, subtype, ability, cost or effect. Each subclass
|
|
8
|
+
# declares its metadata with `primitive`; the declaration both registers it
|
|
9
|
+
# in Enjine.registry and feeds the generated reference docs.
|
|
10
|
+
class Primitive
|
|
11
|
+
CATEGORIES = %i[type supertype subtype keyword ability trigger cost effect filter counter].freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
attr_reader :category, :primitive_name, :description, :rules, :params, :example
|
|
15
|
+
|
|
16
|
+
# Declares this class as the primitive `name` of `category` and registers it.
|
|
17
|
+
def primitive(category, name, description:, rules: [], params: {}, example: nil)
|
|
18
|
+
raise ArgumentError, "unknown category #{category}" unless CATEGORIES.include?(category)
|
|
19
|
+
|
|
20
|
+
@category = category
|
|
21
|
+
@primitive_name = name.to_s
|
|
22
|
+
@description = description
|
|
23
|
+
@rules = rules.freeze
|
|
24
|
+
@params = params.freeze
|
|
25
|
+
@example = example
|
|
26
|
+
Enjine.registry.register(self)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Holds every registered primitive, keyed by category and name.
|
|
32
|
+
class Registry
|
|
33
|
+
def initialize
|
|
34
|
+
@entries = Primitive::CATEGORIES.to_h { |c| [c, {}] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def register(klass)
|
|
38
|
+
bucket = @entries.fetch(klass.category)
|
|
39
|
+
name = klass.primitive_name
|
|
40
|
+
raise ArgumentError, "#{klass.category} primitive #{name.inspect} already registered" if bucket.key?(name) && bucket[name] != klass
|
|
41
|
+
|
|
42
|
+
bucket[name] = klass
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fetch(category, name)
|
|
46
|
+
@entries.fetch(category)[name.to_s]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def names(category)
|
|
50
|
+
@entries.fetch(category).keys.sort
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def each(category, &)
|
|
54
|
+
@entries.fetch(category).sort.map(&:last).each(&)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.registry
|
|
59
|
+
@registry ||= Registry.new
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# A state change about to happen (CR 614.1). Mutations are proposed first, passed
|
|
5
|
+
# through replacement effects, then executed, so "enters tapped", "if it would die,
|
|
6
|
+
# exile it instead" and damage prevention can modify them before they occur.
|
|
7
|
+
#
|
|
8
|
+
# kind: "zone_change" | "damage" | "life" | "draw" | "counters" | "untap" | "create_token"
|
|
9
|
+
# data: plain Hash describing the change; a replacement that stops the change entirely
|
|
10
|
+
# ("instead remove a stun counter") sets data["cancelled"] = true
|
|
11
|
+
# applied: ids of replacement effects already applied (each applies once, CR 614.5)
|
|
12
|
+
Proposal = Struct.new(:kind, :data, :applied)
|
|
13
|
+
|
|
14
|
+
# The replacement-effect pass (CR 614-616).
|
|
15
|
+
#
|
|
16
|
+
# Applies, in order: the entering permanent's own self-replacement effects and the rules' own
|
|
17
|
+
# entering modifications from card types (e.g. a planeswalker enters with loyalty counters,
|
|
18
|
+
# CR 306.5b, 616.1a), entering replacements of permanents already on the battlefield (extra
|
|
19
|
+
# counters on permanents entering under their controller's control, CR 614.1c, 614.12), and the
|
|
20
|
+
# effects of counters on the affected permanent (stun and shield counters, CR 122.1c-d).
|
|
21
|
+
#
|
|
22
|
+
# CR 616.1 lets the affected player (or the affected object's controller) choose the order when
|
|
23
|
+
# several effects apply. The engine applies them in the order that player would always choose:
|
|
24
|
+
# effects that let a permanent stay ("instead remove all +1/+1 counters", a shield counter) before
|
|
25
|
+
# effects that send it elsewhere ("exile it instead"), "+1 counter" bonuses before doubling, and
|
|
26
|
+
# "enters untapped" after every "enters tapped". No pair implemented so far has two orders that
|
|
27
|
+
# each benefit that player, so no decision is asked; a frame will when one does.
|
|
28
|
+
module Replacements
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def apply(engine, proposal)
|
|
32
|
+
engine.cast_replacements!(proposal) if proposal.kind == "zone_change" # beam me up, cast riders (Permissions)
|
|
33
|
+
case proposal.kind
|
|
34
|
+
when "zone_change" then zone_change!(engine, proposal)
|
|
35
|
+
when "counters" then putting_counters!(engine, proposal)
|
|
36
|
+
when "draw" then drawing!(engine, proposal)
|
|
37
|
+
when "damage" then Mechanics::DamageEvents.replace!(engine, proposal) # abilities/damage_replacements.rb
|
|
38
|
+
when "create_token" then creating_token!(engine, proposal)
|
|
39
|
+
end
|
|
40
|
+
Mechanics::Counters.replace!(engine, proposal) unless proposal.data["cancelled"]
|
|
41
|
+
dying!(engine, proposal) if proposal.kind == "zone_change" && !proposal.data["cancelled"]
|
|
42
|
+
proposal
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def zone_change!(engine, proposal)
|
|
46
|
+
return entering!(engine, proposal) if proposal.data["to"] == :battlefield
|
|
47
|
+
|
|
48
|
+
destroying!(engine, proposal)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The first applicable ability of a battlefield permanent controlled by `player` that responds to
|
|
52
|
+
# `hook` and hasn't modified this event yet (CR 614.5). It is marked as applied.
|
|
53
|
+
def next_controlled(engine, proposal, hook, player)
|
|
54
|
+
found = battlefield_abilities(engine, hook).find do |source, _|
|
|
55
|
+
source.controller == player && !proposal.applied.include?("#{source.id}:#{hook}")
|
|
56
|
+
end
|
|
57
|
+
proposal.applied << "#{found[0].id}:#{hook}" if found
|
|
58
|
+
found
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# "If you would draw a card, draw two cards instead" (CR 614.11). Only one applies to this event;
|
|
62
|
+
# the others apply to each of the draws it becomes (Engine#draw!).
|
|
63
|
+
def drawing!(engine, proposal)
|
|
64
|
+
_, ability = next_controlled(engine, proposal, :replace_draw, proposal.data["player"])
|
|
65
|
+
proposal.data["count"] = ability.amount if ability
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# "If one or more tokens would be created under your control, that many [tokens] are created
|
|
69
|
+
# instead" (CR 614.1a, 111.1).
|
|
70
|
+
def creating_token!(engine, proposal)
|
|
71
|
+
_, ability = next_controlled(engine, proposal, :token_definition, proposal.data["player"])
|
|
72
|
+
proposal.data["token"] = ability.token_definition.id if ability
|
|
73
|
+
proposal.data["count"] *= 2 while next_controlled(engine, proposal, :doubles_tokens, proposal.data["player"]) # "twice that many"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# CR 701.8a, 704.5g-h: destroying a permanent, by an effect or for lethal damage / deathtouch.
|
|
77
|
+
DESTROY_REASONS = %w[destroy sba_lethal_damage sba_deathtouch].freeze
|
|
78
|
+
|
|
79
|
+
# "If [a permanent] would be destroyed, instead ..." (CR 614.8-style destruction replacements).
|
|
80
|
+
def destroying!(engine, proposal)
|
|
81
|
+
return unless DESTROY_REASONS.include?(proposal.data["reason"])
|
|
82
|
+
|
|
83
|
+
obj = engine.object(proposal.data["object"]) # destroyed: on the battlefield (CR 701.8a)
|
|
84
|
+
battlefield_abilities(engine, :replace_destroy).each do |source, ability|
|
|
85
|
+
next if proposal.applied.include?("#{source.id}:replace_destroy") || !ability.replaces?(engine, obj, source)
|
|
86
|
+
|
|
87
|
+
proposal.applied << "#{source.id}:replace_destroy"
|
|
88
|
+
proposal.data["cancelled"] = true
|
|
89
|
+
return ability.replace_destroy(engine, obj)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# "If it would die this turn, exile it instead" and "If a creature an opponent controls would die, exile it
|
|
94
|
+
# instead" (CR 614.1a, 700.4).
|
|
95
|
+
def dying!(engine, proposal)
|
|
96
|
+
data = proposal.data
|
|
97
|
+
return unless data["to"] == :graveyard && engine.state.battlefield.include?(data["object"])
|
|
98
|
+
|
|
99
|
+
obj = engine.object(data["object"])
|
|
100
|
+
this_turn = engine.state.effects.any? { it["kind"] == "exile_if_dies" && it["affected"].include?(obj.id) }
|
|
101
|
+
static = battlefield_abilities(engine, :exiles_dying?).find { |source, ability| ability.exiles_dying?(engine, obj, source) }
|
|
102
|
+
data["to"] = :exile if this_turn || static
|
|
103
|
+
# The one replacement that applies (CR 614.5) is the static's when there is one: its follow-up and link.
|
|
104
|
+
(data["follow_ups"] ||= []) << static[1].follow_up_entry(static[0], obj) if static
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Bonus counters from "until end of turn, ... that many plus one of each of those kinds" effects of
|
|
108
|
+
# the permanent's controller (CR 614.1a).
|
|
109
|
+
def counter_bonus(engine, controller)
|
|
110
|
+
engine.state.effects.sum { it["kind"] == "counter_bonus" && it["controller"] == controller ? it["amount"] : 0 }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# [[source permanent, ability]] for abilities of permanents on the battlefield that respond to `hook`.
|
|
114
|
+
def battlefield_abilities(engine, hook)
|
|
115
|
+
engine.state.battlefield.flat_map do |id|
|
|
116
|
+
source = engine.object(id)
|
|
117
|
+
engine.chars(source).abilities.filter_map { [source, it.ability] if it.ability.respond_to?(hook) }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# "Put twice that many counters instead" (CR 614.1a) on counters put on a permanent.
|
|
122
|
+
def putting_counters!(engine, proposal)
|
|
123
|
+
obj = engine.object(proposal.data["object"])
|
|
124
|
+
facts = Mechanics::Filters::Facts.new(chars: engine.chars(obj), counters: obj.counters,
|
|
125
|
+
token: engine.token?(obj))
|
|
126
|
+
proposal.data["amount"] += counter_bonus(engine, obj.controller) # "that many plus one" first (CR 616.1)
|
|
127
|
+
battlefield_abilities(engine, :doubles?).each do |source, ability|
|
|
128
|
+
next unless ability.doubles?(source, proposal.data["player"], obj.controller, proposal.data["counter"], facts)
|
|
129
|
+
|
|
130
|
+
proposal.data["amount"] *= 2
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# CR 614.12-style status the permanent enters with.
|
|
135
|
+
def entering!(engine, proposal)
|
|
136
|
+
obj = engine.object(proposal.data["object"])
|
|
137
|
+
enters = proposal.data["enters"]
|
|
138
|
+
# Self-replacement effects of the entering permanent (CR 614.12: as it would exist on the battlefield,
|
|
139
|
+
# with the face it enters with up, CR 712.9).
|
|
140
|
+
face = engine.entering_face(obj, enters)
|
|
141
|
+
(face || engine.definition(obj)).ability_instances.each do |inst|
|
|
142
|
+
inst.ability.modify_entering(enters) if inst.ability.respond_to?(:modify_entering)
|
|
143
|
+
if inst.ability.respond_to?(:modify_entering_for) # a condition checked for its controller (CR 614.1c)
|
|
144
|
+
inst.ability.modify_entering_for(engine, proposal.data["controller"] || obj.owner, obj.id, enters)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
(face ? face.types : engine.chars(obj).types).each do |type|
|
|
148
|
+
mod = Enjine.registry.fetch(:type, type)
|
|
149
|
+
mod.entering(face || engine.definition(obj), enters) if mod.respond_to?(:entering)
|
|
150
|
+
end
|
|
151
|
+
others_entering!(engine, obj, proposal)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Entering replacements of permanents already on the battlefield (CR 614.1c, 614.12).
|
|
155
|
+
def others_entering!(engine, obj, proposal)
|
|
156
|
+
controller = proposal.data["controller"] || obj.owner
|
|
157
|
+
facts = Mechanics::Filters::Facts.new(chars: engine.chars(obj), counters: {}, token: engine.token?(obj))
|
|
158
|
+
enters = proposal.data["enters"]
|
|
159
|
+
battlefield_abilities(engine, :modify_other_entering).each do |source, ability|
|
|
160
|
+
ability.modify_other_entering(enters) if source.id != obj.id && ability.affects_entering?(source, controller, facts)
|
|
161
|
+
end
|
|
162
|
+
# Counters it enters with are put on it by its controller (CR 122.6, 122.6a).
|
|
163
|
+
bonus = counter_bonus(engine, controller)
|
|
164
|
+
enters["counters"].transform_values! { it + bonus } if bonus.positive?
|
|
165
|
+
battlefield_abilities(engine, :doubles?).each do |source, ability|
|
|
166
|
+
enters["counters"].each_key do |kind|
|
|
167
|
+
enters["counters"][kind] *= 2 if ability.doubles?(source, controller, controller, kind, facts)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
engine.chars(obj).subtypes.each do |sub| # e.g. a Saga enters with a lore counter (CR 715.3a)
|
|
171
|
+
mod = Enjine.registry.fetch(:subtype, sub)
|
|
172
|
+
mod.entering(engine.definition(obj), proposal.data["enters"]) if mod.respond_to?(:entering)
|
|
173
|
+
end
|
|
174
|
+
entering_status!(engine, obj, controller, facts, enters)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# "Lands you control enter untapped" overrides every "enters tapped" (applied last, CR 616.1), and
|
|
178
|
+
# "would enter while you control an attacking creature: instead it enters tapped and attacking".
|
|
179
|
+
def entering_status!(engine, obj, controller, facts, enters)
|
|
180
|
+
battlefield_abilities(engine, :enters_attacking?).each do |source, ability|
|
|
181
|
+
next unless source.id != obj.id && ability.enters_attacking?(engine, source, controller, facts)
|
|
182
|
+
|
|
183
|
+
enters["tapped"] = true
|
|
184
|
+
enters["attacking"] = true
|
|
185
|
+
end
|
|
186
|
+
battlefield_abilities(engine, :enters_untapped?).each do |source, ability|
|
|
187
|
+
enters["tapped"] = false if ability.enters_untapped?(source, controller, facts)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
data/lib/enjine/rng.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# PCG32 (XSH-RR) random number generator.
|
|
5
|
+
#
|
|
6
|
+
# The whole generator state is one 64-bit Integer, so it serializes into a
|
|
7
|
+
# snapshot as a plain number and replays identically on every platform.
|
|
8
|
+
class Rng
|
|
9
|
+
MULTIPLIER = 6_364_136_223_846_793_005
|
|
10
|
+
INCREMENT = 1_442_695_040_888_963_407
|
|
11
|
+
MASK64 = (1 << 64) - 1
|
|
12
|
+
MASK32 = (1 << 32) - 1
|
|
13
|
+
RANGE32 = 1 << 32
|
|
14
|
+
|
|
15
|
+
attr_reader :state
|
|
16
|
+
|
|
17
|
+
def self.from_seed(seed)
|
|
18
|
+
rng = new(0)
|
|
19
|
+
rng.next_u32
|
|
20
|
+
rng.instance_variable_set(:@state, (rng.state + (Integer(seed) & MASK64)) & MASK64)
|
|
21
|
+
rng.next_u32
|
|
22
|
+
rng
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(state)
|
|
26
|
+
@state = Integer(state) & MASK64
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def next_u32
|
|
30
|
+
old = @state
|
|
31
|
+
@state = ((old * MULTIPLIER) + INCREMENT) & MASK64
|
|
32
|
+
xorshifted = (((old >> 18) ^ old) >> 27) & MASK32
|
|
33
|
+
rot = old >> 59
|
|
34
|
+
((xorshifted >> rot) | (xorshifted << ((-rot) & 31))) & MASK32
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Unbiased integer in 0...n (Lemire-free rejection sampling).
|
|
38
|
+
def rand(bound)
|
|
39
|
+
raise ArgumentError, "bound must be in 1..2**32" unless bound.is_a?(Integer) && bound.positive? && bound <= RANGE32
|
|
40
|
+
|
|
41
|
+
threshold = (RANGE32 - bound) % bound
|
|
42
|
+
loop do
|
|
43
|
+
r = next_u32
|
|
44
|
+
return r % bound if r >= threshold
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# In-place Fisher-Yates shuffle.
|
|
49
|
+
def shuffle!(array)
|
|
50
|
+
(array.size - 1).downto(1) do |i|
|
|
51
|
+
j = rand(i + 1)
|
|
52
|
+
array[i], array[j] = array[j], array[i]
|
|
53
|
+
end
|
|
54
|
+
array
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|