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,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# What happened this turn and this game, for effects and conditions that look back ("if a
|
|
6
|
+
# creature died this turn", life a player lost this turn, how many times an ability resolved this
|
|
7
|
+
# turn).
|
|
8
|
+
#
|
|
9
|
+
# Everything is recorded in one place, from the events the engine already emits, before
|
|
10
|
+
# triggered abilities look at the event (so an intervening "if" sees it). A cast that is
|
|
11
|
+
# cancelled never emits SpellCast, so it never counts (CR 601.2i: a spell becomes cast only
|
|
12
|
+
# once casting is complete).
|
|
13
|
+
#
|
|
14
|
+
# Per player (PlayerState#turn_log, reset as each turn begins and kept as #last_turn_log for the turn after;
|
|
15
|
+
# #game_log for the whole game):
|
|
16
|
+
# "spells" card ids of spells the player cast "life_gained" / "life_lost" totals
|
|
17
|
+
# "spells_from" the zone each of those spells was cast from (CR 601.2a), in the same order
|
|
18
|
+
# "lands_played" [card id, zone it was played from] of the lands the player played (CR 305.1)
|
|
19
|
+
# "entered" [card id, types, subtypes] of permanents that entered under their control
|
|
20
|
+
# "died" [card id, types] of their permanents put into a graveyard from the battlefield
|
|
21
|
+
# "left" [card id, types, subtypes] of their permanents that left the battlefield (CR 400.7)
|
|
22
|
+
# "sacrificed" [card id, types, subtypes] of the permanents they sacrificed (CR 701.21a)
|
|
23
|
+
# "attackers" [card id, types, subtypes] of the creatures they attacked with (CR 508.1)
|
|
24
|
+
# "exiled" [card id, types] of their cards put into exile, from anywhere (as it last existed)
|
|
25
|
+
# "returned" permanents returned to their hand from the battlefield
|
|
26
|
+
# "drawn" / "discarded" how many cards they drew (CR 121.2) / discarded (CR 701.9a)
|
|
27
|
+
# "dilemmas" dilemmas they faced (CR 700.17)
|
|
28
|
+
# "noncombat_damage" noncombat damage dealt to them (CR 120.2b)
|
|
29
|
+
# "coin_flips" coins they flipped (CR 705)
|
|
30
|
+
# The game log also counts the turns each player has begun ("turns").
|
|
31
|
+
# Per object (GameObject#memory, stamped with the turn): "attacked" and "resolved" counts.
|
|
32
|
+
module TurnHistory
|
|
33
|
+
def record_history!(event)
|
|
34
|
+
case event
|
|
35
|
+
when Events::TurnBegan
|
|
36
|
+
turn_logs_rolled!
|
|
37
|
+
bump!(game_log(names.index(event.active)), "turns", 1) # "your third turn of the game"
|
|
38
|
+
when Events::SpellCast then cast_recorded!(event)
|
|
39
|
+
when Events::LifeChanged then life_recorded!(event)
|
|
40
|
+
when Events::ZoneChanged
|
|
41
|
+
zone_change_recorded!(event)
|
|
42
|
+
discard_recorded!(event) # mayhem (Engine::AltCasting)
|
|
43
|
+
when Events::DilemmaFaced then bump!(log_of(event.player), "dilemmas", 1)
|
|
44
|
+
when Events::AttackerDeclared then attack_recorded!(event)
|
|
45
|
+
when Events::AttacksDeclared # the attacking creatures' total power, per combat (Triggers::PlayerAttacks)
|
|
46
|
+
(log_of(event.player)["attack_powers"] ||= []) << event.attackers.sum { power(object(it)) }
|
|
47
|
+
when Events::Resolved then resolution_recorded!(event)
|
|
48
|
+
when Events::DamageDealt then damage_recorded!(event)
|
|
49
|
+
when Events::CoinFlipped then bump!(log_of(event.player), "coin_flips", 1) # CR 705.3 (Abilities::FirstFlipsWin)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# This turn's log of the player at `index`.
|
|
54
|
+
def turn_log(index) = player(index).turn_log ||= {}
|
|
55
|
+
|
|
56
|
+
# The previous turn's log of the player at `index` ("last turn", CR 500.1: whoever's turn it was).
|
|
57
|
+
def last_turn_log(index) = player(index).last_turn_log ||= {}
|
|
58
|
+
|
|
59
|
+
# This game's log of the player at `index`.
|
|
60
|
+
def game_log(index) = player(index).game_log ||= {}
|
|
61
|
+
|
|
62
|
+
# How many times this turn the ability `ref` of `source` has finished resolving (CR 608.2).
|
|
63
|
+
def times_resolved(source, ref)
|
|
64
|
+
memory = turned_memory(state.objects[source], "resolved")
|
|
65
|
+
memory ? memory.fetch(ref, 0) : 0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether the permanent attacked this turn (CR 508.1).
|
|
69
|
+
def attacked_this_turn?(id) = attacks_this_turn(id).positive?
|
|
70
|
+
|
|
71
|
+
# How many times the permanent was declared as an attacker this turn ("for the first time each turn").
|
|
72
|
+
def attacks_this_turn(id) = turned_memory(state.objects[id], "attacked")&.fetch("count", 0).to_i
|
|
73
|
+
|
|
74
|
+
# An attack undone before the declaration was legal (CR 508.1c) never happened.
|
|
75
|
+
def attack_withdrawn!(id)
|
|
76
|
+
obj = state.objects[id]
|
|
77
|
+
bump!(object_memory!(id, "attacked"), "count", -1)
|
|
78
|
+
log = turn_log(obj.controller)["attackers"]
|
|
79
|
+
log&.delete_at(log.rindex { it.first == obj.card } || log.size)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether the permanent entered the battlefield this turn (memory "entered", stamped as it enters).
|
|
83
|
+
def entered_this_turn?(obj) = obj.memory["entered"] == state.turn
|
|
84
|
+
|
|
85
|
+
# CR 608.2h: a permanent leaving the battlefield is remembered as it last existed by the abilities it
|
|
86
|
+
# is the source of (on the stack, being activated or waiting to be put on the stack: "source_lki" in
|
|
87
|
+
# their memory) and by the spell or ability resolving now ("its controller" of a destroyed target).
|
|
88
|
+
def remember_last_known!(id, lki)
|
|
89
|
+
info = last_known_memory(lki)
|
|
90
|
+
state.stack.each do |sid|
|
|
91
|
+
obj = object(sid)
|
|
92
|
+
obj.memory["source_lki"] = info.dup if obj.ability? && obj.source == id
|
|
93
|
+
end
|
|
94
|
+
state.pending_triggers.each { (it["memory"] ||= {})["source_lki"] = info.dup if it["source"] == id }
|
|
95
|
+
resolving = state.frames.rfind { it["type"] == "resolve" && it["ctx"] }
|
|
96
|
+
(resolving["ctx"]["lki"] ||= {})[id] = info if resolving
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A spell leaving the stack while a spell or ability resolves ("counter target spell. If the amount of mana
|
|
100
|
+
# spent to cast that spell was less than its mana value, ..."): the resolving one remembers it as it last
|
|
101
|
+
# existed on the stack (CR 608.2h): its controller, mana spent (CR 601.2h) and mana value, X included (CR 202.3e).
|
|
102
|
+
def remember_spell!(obj)
|
|
103
|
+
resolving = state.frames.rfind { it["type"] == "resolve" && it["ctx"] } or return
|
|
104
|
+
(resolving["ctx"]["lki"] ||= {})[obj.id] = { "controller" => obj.controller, "card" => obj.card,
|
|
105
|
+
"mana_spent" => obj.mana_spent&.dup, "mana_from" => obj.memory["mana_from"],
|
|
106
|
+
"mana_value" => Mechanics::Amount.mana_value(self, obj) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The part of last known information an ability keeps in its memory ("source_lki"): plain data.
|
|
110
|
+
def last_known_memory(lki)
|
|
111
|
+
info = { "controller" => lki["controller"], "power" => lki["power"], "toughness" => lki["toughness"],
|
|
112
|
+
"counters" => lki["counters"].dup, "card" => lki["card"], "keywords" => lki["keywords"].dup }
|
|
113
|
+
info["except"] = lki["except"] if lki["except"] # what a copy of it copies (CR 707.2, Engine::Copies)
|
|
114
|
+
info.merge(lki.slice(Mechanics::Linked::EXILED).transform_values(&:dup)) # CR 607.2a (Linked.exiled_with)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# What the resolving spell or ability remembers about an object that left the battlefield during it.
|
|
118
|
+
def resolving_last_known(id) = state.frames.rfind { it["type"] == "resolve" && it["ctx"] }&.dig("ctx", "lki", id)
|
|
119
|
+
|
|
120
|
+
# CR 702.171b: a permanent becomes saddled until the end of the turn; leaving the battlefield ends it (the
|
|
121
|
+
# card is a new object, CR 400.7), and it isn't copiable (it lives on the object, not its characteristics).
|
|
122
|
+
def saddle!(id) = object_memory!(id, "saddled")
|
|
123
|
+
def saddled?(obj) = obj.zone == :battlefield && !turned_memory(obj, "saddled").nil?
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def log_of(player_name) = turn_log(names.index(player_name))
|
|
128
|
+
|
|
129
|
+
def turn_logs_rolled!
|
|
130
|
+
state.players.each do |pl|
|
|
131
|
+
pl.last_turn_log = pl.turn_log || {}
|
|
132
|
+
pl.turn_log = {}
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def bump!(log, key, amount) = log[key] = log.fetch(key, 0) + amount
|
|
137
|
+
|
|
138
|
+
def cast_recorded!(event)
|
|
139
|
+
index = names.index(event.player)
|
|
140
|
+
(turn_log(index)["spells"] ||= []) << event.card
|
|
141
|
+
(turn_log(index)["spells_from"] ||= []) << ((spell = state.objects[event.object]) ? cast_origin(spell) : "hand")
|
|
142
|
+
bump!(game_log(index), "spells", 1)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def life_recorded!(event)
|
|
146
|
+
log = log_of(event.player)
|
|
147
|
+
bump!(log, "life_gained", event.gain) if event.gain.positive? # CR 119.9
|
|
148
|
+
bump!(log, "life_lost", event.loss) if event.loss.positive?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# CR 700.4: "dies" is battlefield to graveyard; the types are those it last had (CR 603.10a).
|
|
152
|
+
# Who it counts for: a permanent's controller as it last existed, or a card's owner.
|
|
153
|
+
def zone_change_recorded!(event)
|
|
154
|
+
entered_recorded!(event) if event.to == "battlefield"
|
|
155
|
+
land_play_recorded!(event) if event.reason == "play_land"
|
|
156
|
+
lki = event.from == "battlefield" ? last_known(event.old_id) : nil
|
|
157
|
+
departed_recorded!(event, lki) if lki
|
|
158
|
+
log = lki ? turn_log(lki["controller"]) : log_of(event.owner)
|
|
159
|
+
case event.reason
|
|
160
|
+
when "draw" then bump!(log_of(event.owner), "drawn", 1)
|
|
161
|
+
when "discard" then bump!(log_of(event.owner), "discarded", 1)
|
|
162
|
+
end
|
|
163
|
+
(log["exiled"] ||= []) << [event.card, (lki ? lki["types"] : printed_types(event.card)).map(&:to_s)] if event.to == "exile"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def entered_recorded!(event)
|
|
167
|
+
obj = state.objects[event.new_id] or return
|
|
168
|
+
(turn_log(obj.controller)["entered"] ||= []) << history_entry(event.card, chars(obj))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# CR 305.1: the player who plays a land controls it as it enters.
|
|
172
|
+
def land_play_recorded!(event)
|
|
173
|
+
index = state.objects[event.new_id]&.controller || names.index(event.owner)
|
|
174
|
+
(turn_log(index)["lands_played"] ||= []) << [event.card, event.from]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def departed_recorded!(event, lki)
|
|
178
|
+
log = turn_log(lki["controller"])
|
|
179
|
+
left = [event.card, lki["types"].map(&:to_s), lki.fetch("subtypes", []).map(&:to_s)]
|
|
180
|
+
(log["left"] ||= []) << left
|
|
181
|
+
(log["died"] ||= []) << left.first(2) if event.to == "graveyard"
|
|
182
|
+
(log["sacrificed"] ||= []) << left if event.reason == "sacrifice"
|
|
183
|
+
bump!(log_of(event.owner), "returned", 1) if event.to == "hand"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# CR 508.1: "you attacked this turn" / "attacked with a Spacecraft"; the attacker remembers it attacked.
|
|
187
|
+
def attack_recorded!(event)
|
|
188
|
+
obj = state.objects[event.object] or return
|
|
189
|
+
bump!(object_memory!(event.object, "attacked"), "count", 1)
|
|
190
|
+
(turn_log(obj.controller)["attackers"] ||= []) << history_entry(obj.card, chars(obj))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def history_entry(card, current) = [card, current.types.map(&:to_s), current.subtypes.map(&:to_s)]
|
|
194
|
+
def printed_types(card) = catalog.fetch(card).types
|
|
195
|
+
|
|
196
|
+
def damage_recorded!(event)
|
|
197
|
+
index = Mechanics::Filters.player_index(self, event.target)
|
|
198
|
+
bump!(turn_log(index), "noncombat_damage", event.amount) if index && !event.combat
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def resolution_recorded!(event)
|
|
202
|
+
obj = state.objects[event.object]
|
|
203
|
+
return unless obj&.ability? && state.objects.key?(obj.source)
|
|
204
|
+
|
|
205
|
+
memory = object_memory!(obj.source, "resolved")
|
|
206
|
+
memory[obj.ability] = memory.fetch(obj.ability, 0) + 1
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# A memory entry of the object that holds only this turn's data.
|
|
210
|
+
def object_memory!(id, key)
|
|
211
|
+
memory = state.objects.fetch(id).memory
|
|
212
|
+
entry = memory[key]
|
|
213
|
+
entry = memory[key] = { "turn" => state.turn } unless entry.is_a?(Hash) && entry["turn"] == state.turn
|
|
214
|
+
entry
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def turned_memory(obj, key)
|
|
218
|
+
entry = obj&.memory&.[](key)
|
|
219
|
+
entry if entry.is_a?(Hash) && entry["turn"] == state.turn
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# What a triggered ability remembers of its source: whether it was cast (CR 400.7d) and kicked (CR 702.33d),
|
|
223
|
+
# how it was cast, the mana spent to cast it (CR 601.2h) and, for a source that just left the battlefield
|
|
224
|
+
# ("when this leaves, put its counters on ..."), how it last existed (CR 603.10a, 608.2h).
|
|
225
|
+
def trigger_memory(source)
|
|
226
|
+
obj = state.objects[source["id"]]
|
|
227
|
+
source_memory = obj&.memory || {}
|
|
228
|
+
memory = source_memory["cast"] ? { "source_cast" => true } : {}
|
|
229
|
+
memory["kicked"] = true if source_memory["kicked"] # CR 702.33d: a kicked permanent's "if it was kicked"
|
|
230
|
+
memory.merge!(source_memory.slice("cast_from", "cast_with", "kicked_with")) # "if its evoke cost was paid" (Engine::AltCasting)
|
|
231
|
+
memory["source_mana_spent"] = obj.mana_spent.dup if obj&.mana_spent
|
|
232
|
+
memory["source_mana_from"] = Snapshot.deep_copy(source_memory["mana_from"]) if source_memory["mana_from"]
|
|
233
|
+
lki = last_known(source["id"])
|
|
234
|
+
memory["source_lki"] = last_known_memory(lki) if lki
|
|
235
|
+
memory
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
include TurnHistory
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Turn structure (CR 500): moving from step to step, the untap step's turn-based actions and the
|
|
6
|
+
# land-play allowance (CR 502, 305.2).
|
|
7
|
+
#
|
|
8
|
+
# Untap step (CR 502.1, 502.3): first, the permanents the active player controlled when they phased
|
|
9
|
+
# out phase in (unless an effect says permanents can't phase in); then the active player chooses
|
|
10
|
+
# which of their "may choose not to untap" permanents stay tapped (an `untap_choice` frame, one
|
|
11
|
+
# decision per such tapped permanent); then the rest of their permanents untap.
|
|
12
|
+
module TurnStructure
|
|
13
|
+
# ---- lands (CR 305.2) ---------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
# How many lands `index` can play this turn: one, plus one for each "you may play an additional
|
|
16
|
+
# land" ability of a permanent they control (CR 305.2). Lands put onto the battlefield by effects
|
|
17
|
+
# aren't played, so they don't count against it (CR 305.2a).
|
|
18
|
+
def land_plays_allowed(index)
|
|
19
|
+
1 + state.battlefield.sum do |id|
|
|
20
|
+
obj = object(id)
|
|
21
|
+
obj.controller == index ? chars(obj).abilities.count { it.ability.kind == :additional_land_plays } : 0
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# CR 305.2a-b: whether `index` may play another land this turn.
|
|
26
|
+
def land_play_available?(index) = player(index).lands_played < land_plays_allowed(index)
|
|
27
|
+
|
|
28
|
+
# ---- the untap step (CR 502) --------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def begin_untap_step!
|
|
31
|
+
index = state.active
|
|
32
|
+
phasing_step!(index) # CR 502.1
|
|
33
|
+
candidates = untap_choice_candidates(index)
|
|
34
|
+
return finish_untap_step!(index, []) if candidates.empty?
|
|
35
|
+
|
|
36
|
+
state.frames << { "type" => "untap_choice", "player" => index, "pending" => candidates, "keep" => [] }
|
|
37
|
+
ask!(nil)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# CR 502.3: tapped permanents of `index` whose "you may choose not to untap" applies, and that
|
|
41
|
+
# would otherwise untap.
|
|
42
|
+
def untap_choice_candidates(index)
|
|
43
|
+
state.battlefield.select do |id|
|
|
44
|
+
obj = object(id)
|
|
45
|
+
current = chars(obj)
|
|
46
|
+
obj.tapped && obj.controller == index && !current.keyword?(:doesnt_untap) && !obj.memory["exerted"] &&
|
|
47
|
+
current.abilities.any? { it.ability.kind == :may_not_untap }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The untap_choice frame: one decision per candidate, then everything else untaps.
|
|
52
|
+
def step_untap_choice!(frame)
|
|
53
|
+
frame["pending"] &= state.battlefield
|
|
54
|
+
if (id = frame["pending"].first)
|
|
55
|
+
ask!("kind" => "untap_choice", "player" => frame["player"], "object" => id)
|
|
56
|
+
return :wait
|
|
57
|
+
end
|
|
58
|
+
finish_untap_step!(frame["player"], frame["keep"])
|
|
59
|
+
:done
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Records the answer to an untap_choice decision.
|
|
63
|
+
def choose_untap!(id, untap)
|
|
64
|
+
frame = state.frames.rfind { it["type"] == "untap_choice" }
|
|
65
|
+
frame["pending"].delete(id)
|
|
66
|
+
frame["keep"] << id unless untap
|
|
67
|
+
ask!(nil)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def finish_untap_step!(index, keep)
|
|
71
|
+
untap_all!(index, keep: keep)
|
|
72
|
+
turn_based_actions_done!
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# ---- turn structure (CR 500) --------------------------------------------
|
|
76
|
+
|
|
77
|
+
def begin_turn!(index)
|
|
78
|
+
end_effects_until_turn_of!(index) # "until your next turn" (CR 611.2a)
|
|
79
|
+
state.turn += 1
|
|
80
|
+
state.active = index
|
|
81
|
+
state.players.each { it.lands_played = 0 }
|
|
82
|
+
state.extra_phases = State.no_extra_phases
|
|
83
|
+
emit Events::TurnBegan.new(turn: state.turn, active: name(index))
|
|
84
|
+
enter_step!(:untap)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def enter_step!(step_name)
|
|
88
|
+
state.extra_phases["combats"] += 1 if step_name == :beginning_of_combat
|
|
89
|
+
state.step = step_name
|
|
90
|
+
state.step_state = "begin"
|
|
91
|
+
state.passes = 0
|
|
92
|
+
state.priority = nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Runs the game forward until a decision is pending or the game is over.
|
|
96
|
+
def advance!
|
|
97
|
+
until state.pending || state.over?
|
|
98
|
+
if (frame = state.frames.last)
|
|
99
|
+
step_frame!(frame)
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
break if state.step.nil?
|
|
103
|
+
|
|
104
|
+
case state.step_state
|
|
105
|
+
when "begin"
|
|
106
|
+
state.step_state = "active"
|
|
107
|
+
emit Events::StepBegan.new(turn: state.turn, step: state.step.to_s, phase: step.phase.to_s)
|
|
108
|
+
step.begin(self)
|
|
109
|
+
when "done" then next_step!
|
|
110
|
+
else raise Error, "engine stalled in #{state.step} (#{state.step_state}) with no pending decision"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Steps the innermost in-progress process (see Enjine::Frames).
|
|
116
|
+
def step_frame!(frame)
|
|
117
|
+
result = Frames.fetch(frame["type"]).step(self, frame)
|
|
118
|
+
state.frames.delete_at(state.frames.rindex { it.equal?(frame) }) if result == :done && state.frames.include?(frame)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Called once a step's turn-based actions (and any decisions they needed) are complete.
|
|
122
|
+
def turn_based_actions_done!
|
|
123
|
+
step.finish(self)
|
|
124
|
+
if !step.priority? && state.step == :cleanup && !state.pending_triggers.empty?
|
|
125
|
+
state.repeat_cleanup = true # CR 514.3a: priority now, then another cleanup step
|
|
126
|
+
state.step_state = "active"
|
|
127
|
+
give_priority!(state.active)
|
|
128
|
+
elsif step.priority?
|
|
129
|
+
give_priority!(state.active)
|
|
130
|
+
else
|
|
131
|
+
ask!(nil)
|
|
132
|
+
state.step_state = "done"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# CR 500.4: mana empties between steps. CR 500.1: proceed to the next step / turn.
|
|
137
|
+
def next_step!
|
|
138
|
+
empty_mana_pools!
|
|
139
|
+
end_combat! if state.step == :end_of_combat # CR 511.3
|
|
140
|
+
return enter_step!(:combat_damage) if second_combat_damage_step? # CR 510.4
|
|
141
|
+
|
|
142
|
+
if state.step == :cleanup && state.repeat_cleanup
|
|
143
|
+
state.repeat_cleanup = false
|
|
144
|
+
return enter_step!(:cleanup)
|
|
145
|
+
end
|
|
146
|
+
index = Steps.index(state.step) + 1
|
|
147
|
+
index += 1 while index < Steps::ORDER.size && Steps::ORDER[index].skip?(self)
|
|
148
|
+
if index >= Steps::ORDER.size
|
|
149
|
+
begin_turn!(take_next_turn!) # an extra turn comes first (CR 500.7)
|
|
150
|
+
else
|
|
151
|
+
following = Steps::ORDER[index]
|
|
152
|
+
enter_step!(following.phase == step.phase ? following.name : phase_after!(step.phase, following.name))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# ---- additional phases (CR 500.8) ---------------------------------------------------
|
|
157
|
+
|
|
158
|
+
# The first step of each kind of phase an effect can add (an added main phase is a postcombat one).
|
|
159
|
+
ADDED_PHASES = { "combat" => :beginning_of_combat, "main" => :postcombat_main }.freeze
|
|
160
|
+
|
|
161
|
+
# "After this phase, there is an additional combat phase [followed by an additional main phase]": the
|
|
162
|
+
# phases are added directly after the current phase (CR 500.8).
|
|
163
|
+
# `attackers` (filter data): "only [these] creatures can attack during that combat phase" (CR 508.1c), the
|
|
164
|
+
# first added combat phase; the queue then holds {"phase": "combat", "attackers": filter} for it.
|
|
165
|
+
def add_phases!(phases, attackers: nil)
|
|
166
|
+
if attackers && (at = phases.index("combat"))
|
|
167
|
+
phases[at] = { "phase" => "combat", "attackers" => attackers }
|
|
168
|
+
end
|
|
169
|
+
state.extra_phases["pending"] << { "after" => step.phase.to_s, "phases" => phases }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The attack restriction of the current added combat phase (CR 508.1c), as a filter; nil when none.
|
|
173
|
+
def combat_attack_filter
|
|
174
|
+
raw = state.extra_phases["attackers"] or return nil
|
|
175
|
+
(@attack_filters ||= {})[raw] ||= Mechanics.build_entry(:filter, raw, [], "attackers")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# As `phase` ends: the first step of the next added phase, else of the phase the turn goes on with
|
|
179
|
+
# (`natural`). Phases added after the same phase come most recently created first (CR 500.8).
|
|
180
|
+
def phase_after!(phase, natural)
|
|
181
|
+
extra = state.extra_phases
|
|
182
|
+
due = extra["pending"].select { it["after"] == phase.to_s }
|
|
183
|
+
extra["pending"] -= due
|
|
184
|
+
due.each { extra["queue"].unshift(*it["phases"]) }
|
|
185
|
+
extra.delete("attackers") # a restriction lasts for its combat phase only
|
|
186
|
+
if extra["queue"].empty?
|
|
187
|
+
resume = extra.delete("resume")
|
|
188
|
+
return resume ? resume.to_sym : natural
|
|
189
|
+
end
|
|
190
|
+
extra["resume"] ||= natural.to_s
|
|
191
|
+
added = extra["queue"].shift
|
|
192
|
+
return ADDED_PHASES.fetch(added) unless added.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
extra["attackers"] = added["attackers"]
|
|
195
|
+
ADDED_PHASES.fetch(added["phase"])
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# ---- ending the turn (CR 725) -------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
# CR 725.1: triggered abilities waiting to be put on the stack cease to exist (725.1a); every object on
|
|
201
|
+
# the stack is exiled, the resolving one included (725.1b); state-based actions are checked (725.1c);
|
|
202
|
+
# combat ends and the game skips to the cleanup step (725.1d), where abilities that triggered meanwhile
|
|
203
|
+
# go on the stack and another cleanup step follows (725.1f, 514.3a). The resolving spell or ability does
|
|
204
|
+
# nothing more (Engine::Resolution sees "turn_ended" on its frame).
|
|
205
|
+
def end_the_turn!
|
|
206
|
+
state.pending_triggers.clear
|
|
207
|
+
state.frames.each { it["turn_ended"] = true if it["type"] == "resolve" }
|
|
208
|
+
state.stack.dup.each do |id|
|
|
209
|
+
object(id).ability? ? remove_stack_object!(id) : move!(id, :exile, reason: "end_the_turn")
|
|
210
|
+
end
|
|
211
|
+
check_state_based_actions!
|
|
212
|
+
end_combat!
|
|
213
|
+
state.extra_phases = State.no_extra_phases.merge("combats" => state.extra_phases["combats"])
|
|
214
|
+
empty_mana_pools! # CR 500.4: the step ends
|
|
215
|
+
enter_step!(:cleanup)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# "If it's the first combat phase of the turn".
|
|
219
|
+
def first_combat_phase? = state.extra_phases["combats"] == 1
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
include TurnStructure
|
|
223
|
+
end
|
|
224
|
+
end
|