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,60 @@
|
|
|
1
|
+
# Adding a mechanic
|
|
2
|
+
|
|
3
|
+
A mechanic is a **primitive**: a small class that registers itself under a name
|
|
4
|
+
that card data can reference. The categories are `type`, `supertype`, `subtype`,
|
|
5
|
+
`keyword`, `ability`, `cost`, `effect` and `filter`.
|
|
6
|
+
|
|
7
|
+
## Checklist
|
|
8
|
+
|
|
9
|
+
1. **Read the rules.** Find the CR section (pinned version in `lib/enjine/version.rb`)
|
|
10
|
+
and decide which primitive category the mechanic belongs to. Check
|
|
11
|
+
`docs/MECHANICS_COVERAGE.md` for its milestone and priority.
|
|
12
|
+
2. **Write the primitive** in `lib/enjine/mechanics/<category>/<mechanic>.rb`. A mechanic's file holds
|
|
13
|
+
its helper primitives too (crew's triggers sit in `keywords/crew.rb`); the shared bases are in
|
|
14
|
+
`<category>/base.rb` (abilities: `stack_abilities.rb`), and it `require_relative`s any other mechanic it extends:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
module Enjine::Mechanics::Effects
|
|
18
|
+
class Mill < Effect
|
|
19
|
+
primitive :effect, :mill,
|
|
20
|
+
description: "Each referenced player mills `amount` cards.",
|
|
21
|
+
rules: %w[701.17a],
|
|
22
|
+
params: { "amount" => "integer", "to" => "selector" },
|
|
23
|
+
example: { "mill" => { "amount" => 2, "to" => "each_opponent" } }
|
|
24
|
+
|
|
25
|
+
def apply(engine, ctx)
|
|
26
|
+
players(engine, ctx).each do |i|
|
|
27
|
+
amount.times { (id = engine.player(i).library.first) && engine.move!(id, :graveyard, reason: "mill") }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `build(params, errors, path, **)` validates the data and pushes path-prefixed
|
|
35
|
+
errors (the `Effect` base class already handles `amount` and `to`).
|
|
36
|
+
- Change state **only** through engine methods that emit events (`move!`,
|
|
37
|
+
`deal_damage!`, `add_counters!`, `change_life!`, `tap!`, …).
|
|
38
|
+
- If the mechanic needs new state, add it to `State`, `State#to_h`,
|
|
39
|
+
`Snapshot.build` and `Snapshot.validate`, plus `View` if players can see it.
|
|
40
|
+
The fuzzer will catch anything you forget.
|
|
41
|
+
3. **Regenerate the docs:** `bundle exec rake docs`. The docs spec fails until you do.
|
|
42
|
+
4. **Test at three levels:**
|
|
43
|
+
- **Unit** (`spec/unit`): data validation and error paths.
|
|
44
|
+
- **Integration** (`spec/integration`): a `build_game` scenario, then legal
|
|
45
|
+
options, then a decision, then the resulting state and events. Cite the rule
|
|
46
|
+
numbers in the example names.
|
|
47
|
+
- **End-to-end:** add a card using the mechanic to the fitting family file in
|
|
48
|
+
`spec/fixtures/cards/` so the fuzzer (`rake fuzz`) plays it in thousands of random positions.
|
|
49
|
+
5. `bundle exec rake` (specs, coverage gate and RuboCop) must pass.
|
|
50
|
+
|
|
51
|
+
## Where things live
|
|
52
|
+
|
|
53
|
+
| You need | Look at |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| A new card type's timing or placement rules | `mechanics/types/` (`permanent?`, `instant_speed?`, `validate`) |
|
|
56
|
+
| A new kind of thing to do while holding priority | `lib/enjine/actions/` + `Actions.register` |
|
|
57
|
+
| A new kind of question for a player | `lib/enjine/decisions/` + `Decisions.register` |
|
|
58
|
+
| Turn-structure hooks | `lib/enjine/steps.rb` |
|
|
59
|
+
| P/T and other characteristics (layers) | `lib/enjine/engine/characteristics.rb` |
|
|
60
|
+
| State-based actions | `lib/enjine/engine/state_based_actions.rb` (`check_permanent_state_based_actions!`) |
|
data/docs/guides/api.md
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
# Using Enjine from Ruby
|
|
2
|
+
|
|
3
|
+
This guide is for code that *calls* Enjine: a game client, a bot, a simulator, a
|
|
4
|
+
reinforcement-learning environment, a test harness. It covers everything public:
|
|
5
|
+
cards and decks, starting games, the decision loop, what players may see, events,
|
|
6
|
+
saving and replaying, the deck builder and the testing tools.
|
|
7
|
+
|
|
8
|
+
- [1. The model in one minute](#1-the-model-in-one-minute)
|
|
9
|
+
- [2. Cards](#2-cards)
|
|
10
|
+
- [3. Decks and players](#3-decks-and-players)
|
|
11
|
+
- [4. Setups and games](#4-setups-and-games)
|
|
12
|
+
- [5. The decision loop](#5-the-decision-loop)
|
|
13
|
+
- [6. Decision kinds and options](#6-decision-kinds-and-options)
|
|
14
|
+
- [7. Ids and references](#7-ids-and-references)
|
|
15
|
+
- [8. Observing a game: views](#8-observing-a-game-views)
|
|
16
|
+
- [9. History and events](#9-history-and-events)
|
|
17
|
+
- [10. Saving, replaying, branching](#10-saving-replaying-branching)
|
|
18
|
+
- [11. Writing a bot or an RL environment](#11-writing-a-bot-or-an-rl-environment)
|
|
19
|
+
- [12. The deck builder](#12-the-deck-builder)
|
|
20
|
+
- [13. Testing tools: RulesMonitor, Fuzz and Verify](#13-testing-tools-rulesmonitor-fuzz-and-verify)
|
|
21
|
+
- [14. The command line](#14-the-command-line)
|
|
22
|
+
- [15. Errors](#15-errors)
|
|
23
|
+
- [16. Guarantees and limits](#16-guarantees-and-limits)
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
gem install enjine
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# Gemfile
|
|
31
|
+
gem "enjine"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
require "enjine" # the engine
|
|
36
|
+
require "enjine/deck_builder" # optional: random decks from a card pool
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Ruby 4.0, no runtime dependencies.
|
|
40
|
+
|
|
41
|
+
## 1. The model in one minute
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
alice = Enjine::Player.new(name: "alice", deck: alice_deck) # a deck document (Hash), see §3
|
|
45
|
+
bob = Enjine::Player.new(name: "bob", deck: bob_deck)
|
|
46
|
+
setup = Enjine::Setup.new(alice, bob) # validate and compile once
|
|
47
|
+
game = setup.new_game(seed: 42) # cheap: make as many as you like
|
|
48
|
+
|
|
49
|
+
until game.over?
|
|
50
|
+
decision = game.pending_decision # who must decide, what kind, every legal option
|
|
51
|
+
game.submit(decision.options.sample) # answer with one of the options, exactly as given
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
game.result # => {"winner" => "bob", "reason" => "life"}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Four ideas carry the whole API:
|
|
58
|
+
|
|
59
|
+
1. **The engine never decides for a player.** Every choice the rules give a
|
|
60
|
+
player — keeping a hand, passing priority, each target, each mana payment,
|
|
61
|
+
each attacker — is one *decision* with a complete list of legal *options*.
|
|
62
|
+
There is no auto-pass and no hidden default.
|
|
63
|
+
2. **Options are plain hashes.** `{type: "cast", object: "lightning_bolt#12"}`.
|
|
64
|
+
You never construct actions; you pick one from the list. Anything not in the
|
|
65
|
+
list raises `Enjine::IllegalDecision` and leaves the game untouched.
|
|
66
|
+
3. **Deterministic.** All randomness (shuffles, die rolls, random choices) comes
|
|
67
|
+
from the game's seeded RNG. Same cards, same seed, same decisions → the same
|
|
68
|
+
game, bit for bit.
|
|
69
|
+
4. **Everything is data.** Cards, decks, decisions, views, events and saved games
|
|
70
|
+
are JSON-compatible hashes.
|
|
71
|
+
|
|
72
|
+
## 2. Cards
|
|
73
|
+
|
|
74
|
+
Enjine ships no cards and never reads card text. Every card is a JSON object
|
|
75
|
+
written in the card DSL, describing what the card *does*:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{ "name": "Lightning Bolt", "mana_cost": "{R}", "types": ["instant"],
|
|
79
|
+
"abilities": [ { "spell": { "targets": ["any_target"],
|
|
80
|
+
"effects": [ { "deal_damage": { "amount": 3, "to": { "target": 0 } } } ] } } ],
|
|
81
|
+
"rarity": "common", "printing": { "set": "fdn", "number": "1" } }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`rarity` and `printing` are optional and ignored by the rules. The full language
|
|
85
|
+
(fields, abilities, effects, filters, costs, multi-face cards, keywords) is in
|
|
86
|
+
the [card DSL guide](card-data-format.md); the exhaustive list of primitives is
|
|
87
|
+
generated in [docs/reference/](../reference).
|
|
88
|
+
|
|
89
|
+
Validate a card without starting a game:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
definition = Enjine::CardDefinition.from_h(card_hash) # frozen, validated
|
|
93
|
+
definition.id # => "lightning_bolt" (slug of the name; the card's id everywhere)
|
|
94
|
+
definition.name # => "Lightning Bolt"
|
|
95
|
+
definition.to_h # => the card data, round-trippable
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A bad card raises `Enjine::InvalidCardDefinition`; `#errors` lists every problem
|
|
99
|
+
with its path:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
rescue Enjine::InvalidCardDefinition => e
|
|
103
|
+
e.errors
|
|
104
|
+
# => ["abilities[0].spell.effects[0].deal_damage.to: required",
|
|
105
|
+
# "toughness: creatures need an integer toughness (CR 302.4)", ...]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A `Catalog` is a set of definitions keyed by id. You rarely build one yourself
|
|
109
|
+
(decks and setups do), but it's what `Game.import` needs:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
catalog = Enjine::Catalog.from_definitions(cards) # Hashes or CardDefinitions
|
|
113
|
+
catalog.fetch("lightning_bolt") # by id ...
|
|
114
|
+
catalog.fetch("Lightning Bolt") # ... or by name
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Two different cards with the same name raise `Enjine::CatalogConflict`.
|
|
118
|
+
|
|
119
|
+
## 3. Decks and players
|
|
120
|
+
|
|
121
|
+
A **deck document** carries the cards it uses, so it is self-contained:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"name": "gruul_beats",
|
|
126
|
+
"description": "optional, free text",
|
|
127
|
+
"cards": [ { "name": "Forest", "types": ["land"], "supertypes": ["basic"], "subtypes": ["forest"] },
|
|
128
|
+
{ "name": "Grizzly Bears", "mana_cost": "{1}{G}", "types": ["creature"], "power": 2, "toughness": 2 } ],
|
|
129
|
+
"main": [ { "card": "Forest", "count": 24 }, { "card": "Grizzly Bears", "count": 2 }, "..." ],
|
|
130
|
+
"outside": [ { "card": "Grizzly Bears", "count": 2 } ]
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- `cards`: card data blocks (§2). `main` and `outside` refer to them by name or id.
|
|
135
|
+
- `main`: the decklist. Constructed rules apply (CR 100.2a): at least 60 cards,
|
|
136
|
+
at most 4 copies of a card unless it is a basic land or says otherwise
|
|
137
|
+
(`"deck_limit"` in the card data).
|
|
138
|
+
- `outside` (optional): cards the player owns outside the game — the sideboard
|
|
139
|
+
that "from outside the game" effects use (CR 400.11). At most 15 (CR 100.4a);
|
|
140
|
+
the copy limit counts both lists.
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
player = Enjine::Player.new(name: "alice", deck: doc) # doc: Hash or Enjine::Deck
|
|
144
|
+
player.ready? # => true when the deck is legal
|
|
145
|
+
player.deck.errors # => ["deck has 10 cards; at least 60 are required (CR 100.2a)", ...]
|
|
146
|
+
player.deck.size # => 60
|
|
147
|
+
player.deck.entries # => {"forest" => 24, "grizzly_bears" => 4, ...}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
An illegal deck can still be built and inspected; it just isn't `ready?`, and
|
|
151
|
+
`Setup.new` refuses it with `Enjine::PlayerNotReady` (whose `#errors` are the
|
|
152
|
+
deck's). A malformed document (not a Hash, `main` not a list, unknown card
|
|
153
|
+
reference) raises `Enjine::InvalidDeck`.
|
|
154
|
+
|
|
155
|
+
While a player is seated in an unfinished game, editing their deck raises
|
|
156
|
+
`Enjine::DeckLocked`.
|
|
157
|
+
|
|
158
|
+
## 4. Setups and games
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
setup = Enjine::Setup.new(alice, bob)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A `Setup` is a frozen, compiled pairing of two ready players: their cards are
|
|
165
|
+
validated and merged into one catalog once. Player names must differ. Spawning
|
|
166
|
+
games from it is cheap, so keep one `Setup` per matchup and create games from it:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
game = setup.new_game(seed: 42) # Integer seed; drives every random event of the game
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The game starts at its very first decision: the die-roll winner chooses to play
|
|
173
|
+
or draw (CR 103.1), then each player decides on a mulligan.
|
|
174
|
+
|
|
175
|
+
| `Game` method | Returns |
|
|
176
|
+
| --- | --- |
|
|
177
|
+
| `pending_decision` | the `Decision` the engine waits for, or `nil` when the game is over |
|
|
178
|
+
| `legal_options` | `pending_decision.options` (or `[]`) |
|
|
179
|
+
| `submit(option)` | applies one option, returns the `HistoryEntry` it produced |
|
|
180
|
+
| `apply([options])` | submits several, all or nothing; returns the game |
|
|
181
|
+
| `concede(player)` | the player concedes (CR 104.3a), at any time |
|
|
182
|
+
| `over?`, `result` | `result` is `{"winner" => name or nil, "reason" => "..."}` once over |
|
|
183
|
+
| `turn`, `step`, `names` | turn number, current step as a Symbol (`:precombat_main`), the two names |
|
|
184
|
+
| `view_for(viewer)` | what a player (by name) or `:observer` may see (§8) |
|
|
185
|
+
| `history`, `history_for(viewer)`, `decision_log` | everything that happened (§9) |
|
|
186
|
+
| `subscribe { \|event\| }` | live typed events (§9) |
|
|
187
|
+
| `export`, `to_h` | a JSON snapshot / its Hash (§10) |
|
|
188
|
+
| `fork` | an independent copy for search and lookahead (§10) |
|
|
189
|
+
| `restart(seed: nil)` | back to the opening, same or new seed |
|
|
190
|
+
| `catalog` | the cards of this game |
|
|
191
|
+
|
|
192
|
+
`result["reason"]` is why the loser lost: `life` (CR 704.5a), `empty_library`
|
|
193
|
+
(drew from an empty library, 704.5b), `poison` (704.5c), `concede` (104.3a),
|
|
194
|
+
`effect` ("you lose the game") or `opponent_won` ("you win the game"). When both
|
|
195
|
+
players lose at once the game is a draw: `"winner" => nil` and the reasons are
|
|
196
|
+
joined, e.g. `"life,life"` (CR 104.4a).
|
|
197
|
+
|
|
198
|
+
## 5. The decision loop
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
decision = game.pending_decision
|
|
202
|
+
decision.player # => "alice" who must decide
|
|
203
|
+
decision.kind # => "choose_target" what kind of decision (§6)
|
|
204
|
+
decision.options # => [{type: "target", target: "player:bob"}, {type: "cancel"}, ...]
|
|
205
|
+
decision.details # => {"index" => 0} extra context for this kind
|
|
206
|
+
decision.to_h # => {"player" => ..., "kind" => ..., "options" => [...], "index" => 0}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Real examples (`decision.to_h`, from an actual game):
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{"player":"alice","kind":"play_or_draw","options":[{"type":"play"},{"type":"draw"}]}
|
|
213
|
+
{"player":"bob","kind":"mulligan","options":[{"type":"keep"},{"type":"mulligan"}]}
|
|
214
|
+
{"player":"alice","kind":"choose_target","index":0,
|
|
215
|
+
"options":[{"type":"target","target":"player:alice"},{"type":"target","target":"player:bob"},{"type":"cancel"}]}
|
|
216
|
+
{"player":"alice","kind":"pay_cost","remaining":"{R}",
|
|
217
|
+
"options":[{"type":"activate_mana","object":"mountain#137","ability":"intrinsic:mountain"},{"type":"cancel"}]}
|
|
218
|
+
{"player":"bob","kind":"declare_attackers",
|
|
219
|
+
"options":[{"type":"done"},{"type":"attack","object":"horned_turtle#149","target":"player:alice"}]}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Submitting.** Pass one of the options back. Symbol or string keys both work, so
|
|
223
|
+
options that went through JSON can be submitted as they are:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
game.submit(decision.options.first)
|
|
227
|
+
game.submit({"type" => "pass"}) # same as {type: "pass"}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
An option that isn't legal raises `Enjine::IllegalDecision` (`#option`,
|
|
231
|
+
`#legal_options`) and changes nothing. Submitting after the game ended raises too.
|
|
232
|
+
|
|
233
|
+
**Priority.** Most decisions are `priority`: the player holding priority picks an
|
|
234
|
+
action (`play_land`, `cast`, `activate`, `activate_mana`) or `{type: "pass"}`.
|
|
235
|
+
When both players pass in succession with an empty stack, the step ends; with
|
|
236
|
+
something on the stack, its top resolves (CR 117.4). The engine never passes
|
|
237
|
+
for anyone: a client that wants auto-pass submits `pass` itself when it is the
|
|
238
|
+
only option (the CLI does this for human seats).
|
|
239
|
+
|
|
240
|
+
### Casting is several small decisions
|
|
241
|
+
|
|
242
|
+
Casting a spell (CR 601.2) is a short conversation, each step its own decision.
|
|
243
|
+
Lightning Bolt with a Mountain in play:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
game.submit(type: "cast", object: "lightning_bolt#12") # priority: the card moves to the stack
|
|
247
|
+
game.submit(type: "target", target: "player:bob") # choose_target
|
|
248
|
+
game.submit(type: "activate_mana", object: "mountain#3",
|
|
249
|
+
ability: "intrinsic:mountain") # pay_cost: tap the Mountain, {R} in pool
|
|
250
|
+
game.submit(type: "pay_mana", mana: "R") # pay_cost: spend it -> the spell is cast
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Depending on the card, the engine also asks for modes (`choose_mode`), X
|
|
254
|
+
(`choose_x`), kicker (`choose_kicker`), the number of targets
|
|
255
|
+
(`choose_target_count`), how to divide damage (`distribute`), or which permanent
|
|
256
|
+
pays a cost (`choose_cost`). Abilities (`activate`) work the same way.
|
|
257
|
+
|
|
258
|
+
**Cancelling.** Until the last payment, `{type: "cancel"}` is offered and rewinds
|
|
259
|
+
the whole cast: the card returns to its place in hand, sources tapped for it
|
|
260
|
+
untap and the mana pool is restored (CR 734). It stops being offered once
|
|
261
|
+
something irreversible is paid (a Treasure was sacrificed for mana).
|
|
262
|
+
|
|
263
|
+
### Combat
|
|
264
|
+
|
|
265
|
+
`declare_attackers` and `declare_blockers` collect one creature at a time:
|
|
266
|
+
`{type: "attack", object:, target:}` / `{type: "block", object:, attacker:}`,
|
|
267
|
+
then `{type: "done"}`. `done` is only offered when the declaration is legal
|
|
268
|
+
(menace, "attacks each combat if able", "can't attack alone"); `withdraw` /
|
|
269
|
+
`unblock` undo a pick. When a blocked creature's damage has to be split,
|
|
270
|
+
`assign_damage` asks one point at a time.
|
|
271
|
+
|
|
272
|
+
## 6. Decision kinds and options
|
|
273
|
+
|
|
274
|
+
| Kind | When | Options |
|
|
275
|
+
| --- | --- | --- |
|
|
276
|
+
| `play_or_draw` | die-roll winner, game start | `play`, `draw` |
|
|
277
|
+
| `mulligan` | each player, in turn order | `keep`, `mulligan` (London) |
|
|
278
|
+
| `bottom` | after a mulligan | `{type: "bottom", object:}` once per mulligan |
|
|
279
|
+
| `priority` | most of the game | `pass`, `play_land`, `cast`, `activate`, `activate_mana` |
|
|
280
|
+
| `choose_mode` | modal spell or ability | `{type: "mode", mode: i}`, `done`, `cancel` |
|
|
281
|
+
| `choose_x` | {X} in a cost | `{type: "x", value: n}`, `cancel` |
|
|
282
|
+
| `choose_kicker` | an optional cost: kicker, optional additional cost, gift, teamwork | `kick`, `no_kick`, `cancel` |
|
|
283
|
+
| `choose_cost_option` | a choice between costs ("behold a Goblin or pay {2}") | `{type: "cost_option", option: i}`, `cancel` |
|
|
284
|
+
| `choose_target_count` | "X target ...", "any number of target ..." | `{type: "count", count: n}`, `cancel` |
|
|
285
|
+
| `choose_target` | each target | `{type: "target", target: ref}`, `done` (for "up to"), `cancel` |
|
|
286
|
+
| `distribute` | dividing damage/counters | `{type: "assign", target: ref}`, `cancel` |
|
|
287
|
+
| `choose_cost` | a cost that picks permanents/cards (sacrifice, crew, station, ...) | `{type: "cost", object:}`, `done`, `cancel` |
|
|
288
|
+
| `pay_cost` | paying mana | `activate_mana`, `pay_mana`, `convoke`, `improvise`, `waterbend`, `cancel` |
|
|
289
|
+
| `order_triggers` | several of your triggers at once | `{type: "trigger", trigger: id}` |
|
|
290
|
+
| `effect_choice` | a choice while something resolves (scry, surveil, "you may", choose a card, ...) | one of the listed `choices`; `prompt` describes it |
|
|
291
|
+
| `enter_choice` | as a permanent enters ("choose a color", "pay 2 life or enters tapped", "behold or enters tapped") | `color`, `creature_type`, `number`, `name`, `option`, `pay_life`, `behold`, `enter_tapped`, ... |
|
|
292
|
+
| `untap_choice` | "you may choose not to untap" | `untap`, `keep_tapped` |
|
|
293
|
+
| `legend_rule` | two legends with one name | `{type: "keep", object:}` |
|
|
294
|
+
| `declare_attackers` | your combat | `attack`, `withdraw`, `done` |
|
|
295
|
+
| `declare_blockers` | opponent's combat | `block`, `unblock`, `done` |
|
|
296
|
+
| `assign_damage` | splitting combat damage | `{type: "assign", object:}` |
|
|
297
|
+
| `discard` | cleanup, over 7 cards | `{type: "discard", object:}` |
|
|
298
|
+
|
|
299
|
+
The generated [decision reference](../reference/decisions.md) documents every
|
|
300
|
+
kind's options and `details` exactly. Treat the option list as the source of
|
|
301
|
+
truth: new mechanics add kinds and option types, and a client that renders or
|
|
302
|
+
picks from `options` generically keeps working.
|
|
303
|
+
|
|
304
|
+
## 7. Ids and references
|
|
305
|
+
|
|
306
|
+
| Thing | Form | Example |
|
|
307
|
+
| --- | --- | --- |
|
|
308
|
+
| card definition | slug of its name | `"lightning_bolt"` |
|
|
309
|
+
| game object (card or token in a zone, spell, ability on the stack) | `"<card id>#<n>"` | `"lightning_bolt#12"` |
|
|
310
|
+
| player | name, or `"player:<name>"` where a target is expected | `"alice"`, `"player:alice"` |
|
|
311
|
+
| ability of an object | ref string | `"printed:0"`, `"intrinsic:mountain"`, `"keyword:station"`, `"granted:<card>:printed:0:0"` |
|
|
312
|
+
| card outside the game | `"outside:<card id>"` | `"outside:negate"` |
|
|
313
|
+
|
|
314
|
+
An object's id lasts while it stays in one zone. Moving zones makes a new object
|
|
315
|
+
with a new id (CR 400.7); the `zone_changed` event carries `old_id` and `new_id`,
|
|
316
|
+
so you can follow a card.
|
|
317
|
+
|
|
318
|
+
## 8. Observing a game: views
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
game.view_for("alice") # what alice may know
|
|
322
|
+
game.view_for(:observer) # everything (both hands, library order)
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
A view is a plain Hash:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
{
|
|
329
|
+
"viewer" => "alice", "turn" => 7, "active" => "bob", "step" => "declare_attackers",
|
|
330
|
+
"phase" => "combat", "priority" => "bob",
|
|
331
|
+
"players" => [ ...one per player... ],
|
|
332
|
+
"battlefield" => [ ...objects... ], "stack" => [...], "exile" => [...], "phased_out" => [...],
|
|
333
|
+
"pending" => { ...the pending decision, as this viewer may see it... },
|
|
334
|
+
"result" => nil,
|
|
335
|
+
"effects" => [ ...continuous effects in force (pumps, grants, control changes)... ],
|
|
336
|
+
"delayed_triggers" => [ ...waiting delayed triggers... ]
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
A player entry:
|
|
341
|
+
|
|
342
|
+
```json
|
|
343
|
+
{"name":"alice","life":18,"poison":0,"mana_pool":{"R":1},"restricted_mana":[],"lands_played":1,
|
|
344
|
+
"mulligans":0,"hand_count":6,"library_count":43,"outside_count":0,"graveyard":[ ... ],
|
|
345
|
+
"hand":[ ... ], "outside":[ ... ]}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
`hand` and `outside` appear only for their owner (and the observer); `library`
|
|
349
|
+
(in order, top first) only for the observer; `speed` once the player has one.
|
|
350
|
+
|
|
351
|
+
A permanent shows its **current** characteristics, after counters and continuous
|
|
352
|
+
effects — what the rules use, not what's printed:
|
|
353
|
+
|
|
354
|
+
```json
|
|
355
|
+
{"id":"savannah_lions#187","card":"savannah_lions","name":"Savannah Lions","type_line":"Creature — Cat",
|
|
356
|
+
"owner":"bob","controller":"bob","mana_cost":"{W}","keywords":["double_strike"],"tapped":true,
|
|
357
|
+
"power":3,"toughness":2,"damage":0,"summoning_sick":false,"counters":{"+1/+1":1},
|
|
358
|
+
"attacking":"player:alice"}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Other keys appear when they apply: `loyalty`, `blocking`, `attached_to`,
|
|
362
|
+
`transformed`, `prepared`. Face-down exiled cards show only `{"face_down": true}`
|
|
363
|
+
to players who may not look at them (CR 406.3).
|
|
364
|
+
|
|
365
|
+
## 9. History and events
|
|
366
|
+
|
|
367
|
+
Every `submit` returns a `HistoryEntry`: the decision and every event it caused.
|
|
368
|
+
|
|
369
|
+
```json
|
|
370
|
+
{"seq":33,"turn":2,"step":"declare_attackers","player":"alice","kind":"pay_cost",
|
|
371
|
+
"option":{"type":"pay_mana","mana":"R"},
|
|
372
|
+
"events":[{"type":"mana_paid","player":"alice","mana":"{R}","object":"lightning_bolt#138"},
|
|
373
|
+
{"type":"spell_cast","player":"alice","object":"lightning_bolt#138","card":"lightning_bolt",
|
|
374
|
+
"targets":["player:bob"],"during_resolution":false}]}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
```ruby
|
|
378
|
+
game.history # all HistoryEntry objects (entry.to_h = observer form)
|
|
379
|
+
game.history_for("bob") # Hashes, hidden information redacted for bob
|
|
380
|
+
game.subscribe { |event| } # called with each typed event as it happens
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Redaction follows the rules on hidden information: bob doesn't learn which card
|
|
384
|
+
alice drew or put on the bottom, but he does see that she drew one, and every
|
|
385
|
+
public move in full.
|
|
386
|
+
|
|
387
|
+
**Events** are `Data` objects (`event.type`, `event.to_h`, `event.for_viewer(name)`),
|
|
388
|
+
all listed in `Enjine::Events::REGISTRY`. The common ones:
|
|
389
|
+
|
|
390
|
+
| Event | Fields |
|
|
391
|
+
| --- | --- |
|
|
392
|
+
| `game_started`, `die_rolled`, `starting_player_chosen` | `players`, `seed` / `winner` / `chooser`, `choice`, `starting_player` |
|
|
393
|
+
| `mulligan_declared`, `mulligan_taken`, `hand_kept` | `player`, `choice` / `mulligans` |
|
|
394
|
+
| `turn_began`, `step_began` | `turn`, `active` / `turn`, `step`, `phase` |
|
|
395
|
+
| `priority_passed` | `player` |
|
|
396
|
+
| `zone_changed` | `card`, `old_id`, `new_id`, `owner`, `from`, `to`, `reason` (`draw`, `play_land`, `cast`, `resolve`, `destroy`, `sacrifice`, `countered`, `sba_lethal_damage`, ...) |
|
|
397
|
+
| `spell_cast`, `ability_activated`, `cast_cancelled`, `resolved`, `fizzled` | `player`, `object`, `card`, `targets`, ... |
|
|
398
|
+
| `ability_triggered`, `trigger_put_on_stack` | `player`, `source`, `card`, `ability` |
|
|
399
|
+
| `tapped`, `untapped`, `mana_added`, `mana_paid`, `mana_emptied` | `object` / `player`, `mana` |
|
|
400
|
+
| `attacker_declared`, `blocker_declared`, `became_blocked`, `damage_assigned` | `object`, `target` / `attacker` / `blockers` |
|
|
401
|
+
| `damage_dealt` | `source`, `target`, `amount`, `combat` |
|
|
402
|
+
| `life_changed` | `player`, `from`, `to` |
|
|
403
|
+
| `counters_changed` | `object`, `counter`, `from`, `to` |
|
|
404
|
+
| `effect_started`, `effects_ended` | `kind`, `affected`, `details` / `count` |
|
|
405
|
+
| `control_changed`, `attached`, `transformed`, ... | mechanic-specific |
|
|
406
|
+
| `player_lost`, `game_ended` | `player`, `reason` / `winner`, `reason` |
|
|
407
|
+
|
|
408
|
+
## 10. Saving, replaying, branching
|
|
409
|
+
|
|
410
|
+
```ruby
|
|
411
|
+
json = game.export # any moment, even halfway through a cast
|
|
412
|
+
game = setup.import(json) # or Enjine::Game.import(json, catalog: setup.catalog)
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
The snapshot holds the full state and history. Import checks it for
|
|
416
|
+
consistency and raises `Enjine::InvalidSnapshot` (with `#errors`) if it's
|
|
417
|
+
malformed. A snapshot needs the same cards (`catalog`) to load.
|
|
418
|
+
|
|
419
|
+
**Replay.** The seed plus the decision log reproduces a game exactly:
|
|
420
|
+
|
|
421
|
+
```ruby
|
|
422
|
+
log = game.decision_log # every submitted option
|
|
423
|
+
replay = setup.new_game(seed: 42).apply(log) # identical game
|
|
424
|
+
replay.export == game.export # => true
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
`apply` is all or nothing: if option *i* is illegal it raises
|
|
428
|
+
`IllegalDecision` with `#index == i` and the game is unchanged.
|
|
429
|
+
|
|
430
|
+
**Branching.** `game.fork` returns an independent copy (state and history; not
|
|
431
|
+
subscribers). It's the tool for search: fork, try a line, look at the result,
|
|
432
|
+
throw it away. A fork takes well under a millisecond on a mid-game board.
|
|
433
|
+
|
|
434
|
+
```ruby
|
|
435
|
+
best = game.legal_options.max_by do |option|
|
|
436
|
+
trial = game.fork
|
|
437
|
+
trial.submit(option)
|
|
438
|
+
evaluate(trial.view_for(game.pending_decision.player))
|
|
439
|
+
end
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
`game.restart(seed:)` resets to the opening with the same decks.
|
|
443
|
+
|
|
444
|
+
## 11. Writing a bot or an RL environment
|
|
445
|
+
|
|
446
|
+
An agent is anything that turns a `Decision` into one of its options:
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
class MyAgent
|
|
450
|
+
def choose(decision) = decision.options.first # your policy here
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
agents = { "alice" => MyAgent.new, "bob" => Enjine::Agents::Random.new(seed: 7) }
|
|
454
|
+
game = setup.new_game(seed: 1)
|
|
455
|
+
game.submit(agents.fetch(game.pending_decision.player).choose(game.pending_decision)) until game.over?
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
`Enjine::Agents::Random.new(seed:, cancel_rate: 0.02)` picks uniformly (rarely
|
|
459
|
+
cancelling a cast), and is the baseline opponent.
|
|
460
|
+
|
|
461
|
+
For reinforcement learning:
|
|
462
|
+
|
|
463
|
+
- **Action space**: the current decision's `options`. They are already the
|
|
464
|
+
legal-action mask; encode them (e.g. by `type` + referenced object features)
|
|
465
|
+
and choose among them. Small decisions keep the branching factor low: a cast
|
|
466
|
+
is several picks rather than one huge combined action.
|
|
467
|
+
- **Observation**: `view_for(player)` gives exactly the information that player
|
|
468
|
+
is entitled to (no peeking at hands or library order). `view_for(:observer)` is
|
|
469
|
+
there for privileged critics or debugging.
|
|
470
|
+
- **Reward**: `game.result` when `over?`; intermediate signals from events
|
|
471
|
+
(`life_changed`, `zone_changed` with reason `sba_lethal_damage`, ...) via
|
|
472
|
+
`subscribe` or the returned history entries.
|
|
473
|
+
- **Episodes**: compile the `Setup` once per matchup; `new_game(seed:)` per
|
|
474
|
+
episode. Seeds make every episode reproducible.
|
|
475
|
+
- **Throughput**: about 4,000 decisions per second single-threaded with the
|
|
476
|
+
random agent on the example decks (`bundle exec rake bench` measures it on
|
|
477
|
+
your machine). Games share nothing mutable, so parallel rollouts can run one
|
|
478
|
+
game per process (or per thread).
|
|
479
|
+
|
|
480
|
+
## 12. The deck builder
|
|
481
|
+
|
|
482
|
+
`require "enjine/deck_builder"`. One builder holds a card pool and builds as many
|
|
483
|
+
random, legal, plausibly shaped decks from it as you ask. It knows nothing about
|
|
484
|
+
sets: the pool is whatever cards you give it.
|
|
485
|
+
|
|
486
|
+
```ruby
|
|
487
|
+
builder = Enjine::DeckBuilder.new(cards) # Array of card Hashes / CardDefinitions
|
|
488
|
+
deck = builder.build(colors: %w[W U], seed: 7) # chosen colors
|
|
489
|
+
deck = builder.build(colors: 2, seed: 8) # two random colors
|
|
490
|
+
deck = builder.build(seed: 9, size: 80) # :any colors (1, 2 or 3, weighted)
|
|
491
|
+
deck = builder.build(seed: 10, profile: { lands: { range: 16..18, center: 17, outliers: 16..18 } })
|
|
492
|
+
player = Enjine::Player.new(name: "bob", deck: deck)
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
- A pool entry may be `{"card" => card, "count" => n}`: you own n copies, so no
|
|
496
|
+
deck holds more (a personal collection).
|
|
497
|
+
- Each count (lands 22–26, creatures 14–24, the rest other spells; mana curve;
|
|
498
|
+
copies per card; nonbasic lands; color balance) is drawn from a range, with
|
|
499
|
+
occasional outliers; all of them can be overridden with `profile:` (see
|
|
500
|
+
`DeckBuilder::DEFAULT_PROFILE`). Counts scale with `size:`.
|
|
501
|
+
- Only cards the chosen colors can cast are used; basic lands always exist.
|
|
502
|
+
- Same pool + arguments + seed → same deck, whatever order the pool is in.
|
|
503
|
+
- `build` returns a deck document with a `description` like
|
|
504
|
+
`"random deck: colors=WG seed=3 — 23 lands, 25 creatures, 12 other spells, 14 distinct cards"`.
|
|
505
|
+
- If the pool can't fill the deck: `DeckBuilder::NotEnoughCards`, with a message
|
|
506
|
+
and `#details` (`{pool:, colors:, creatures_available:, missing:, ...}`).
|
|
507
|
+
|
|
508
|
+
More in the [deck builder guide](deck-builder.md).
|
|
509
|
+
|
|
510
|
+
## 13. Testing tools: RulesMonitor, Fuzz and Verify
|
|
511
|
+
|
|
512
|
+
`Enjine::RulesMonitor` is an independent rules checker: it re-derives from raw
|
|
513
|
+
state and events what must be true after each decision (life totals, mana
|
|
514
|
+
emptying, lethal damage, summoning sickness, land plays, P/T, ...), without using
|
|
515
|
+
the engine's rules code.
|
|
516
|
+
|
|
517
|
+
```ruby
|
|
518
|
+
monitor = Enjine::RulesMonitor.new(game)
|
|
519
|
+
monitor.check!(game.submit(option)) # after every submit
|
|
520
|
+
monitor.findings # => [] or [Finding(rule: "704.5g", message:, turn:, step:, seq:)]
|
|
521
|
+
monitor.stats # mechanics seen, e.g. {"spell_cast" => 12, "damage_dealt" => 30}
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
`Enjine::Fuzz` plays random games with invariant checks, the monitor and
|
|
525
|
+
export/import round-trips, and raises `Fuzz::Failure` (with `seed` and
|
|
526
|
+
`decisions`) on any problem:
|
|
527
|
+
|
|
528
|
+
```ruby
|
|
529
|
+
result = Enjine::Fuzz.run(setup, games: 50, seed: 1, roundtrip_every: 25)
|
|
530
|
+
result.games; result.decisions; result.outcomes; result.casts; result.seconds
|
|
531
|
+
Enjine::Fuzz.random_deck(cards, rng: Enjine::Rng.from_seed(3)) # a random legal deck, any colors
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Use them when you write your own cards: a game that breaks names the seed to
|
|
535
|
+
reproduce it.
|
|
536
|
+
|
|
537
|
+
`Enjine::Verify` (`require "enjine/verify"`) does all of this for a folder of
|
|
538
|
+
card files at once. It reads every `.json` file below the folder (each holds one
|
|
539
|
+
card or an array of cards), validates every card, builds decks from the valid
|
|
540
|
+
ones so that every card is in some deck, and plays games watched by the rules
|
|
541
|
+
monitor. It records which cards were actually used (cast, played, activated,
|
|
542
|
+
triggered or resolved), and gives cards that were never used extra decks of their
|
|
543
|
+
own:
|
|
544
|
+
|
|
545
|
+
```ruby
|
|
546
|
+
require "enjine/verify"
|
|
547
|
+
report = Enjine::Verify.new("my_cards/", games: 20, seed: 1).run
|
|
548
|
+
report["ok"] # false if any card is invalid, the monitor found a rules error, or the engine raised
|
|
549
|
+
report["invalid"] # [{"file", "index", "name", "errors" => ["types[0]: unknown type ..."]}]
|
|
550
|
+
report["unexercised"] # valid cards no game ever used: write a scenario for them
|
|
551
|
+
report["findings"] # rules-monitor findings, with the seed and both deck descriptions
|
|
552
|
+
report["types"] # per card type: total / valid / exercised
|
|
553
|
+
puts Enjine::Verify.text(report)
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
The same folder, `games` and `seed` give the same report. Two cards with the same
|
|
557
|
+
name but different rules data count as invalid; an identical reprint counts once.
|
|
558
|
+
|
|
559
|
+
## 14. The command line
|
|
560
|
+
|
|
561
|
+
With the gem installed the command is `enjine`; from a checkout, `bundle exec exe/enjine`.
|
|
562
|
+
|
|
563
|
+
```sh
|
|
564
|
+
exe/enjine --deck1 a.json --deck2 b.json # hot-seat: you play both sides
|
|
565
|
+
exe/enjine --deck1 a.json --deck2 b.json --p2 random # against the random agent
|
|
566
|
+
exe/enjine --deck1 a.json --deck2 b.json --p1 random --p2 random --seed 7 [--quiet] [--observer]
|
|
567
|
+
exe/enjine --pool cards.json --deck1 random:colors=RG --deck2 random # random decks from a pool
|
|
568
|
+
exe/enjine --pool cards.json --random-deck colors=WU seed=7 # print a random deck document
|
|
569
|
+
exe/enjine --validate cards.json # JSON report; exit 1 if any card is invalid
|
|
570
|
+
exe/enjine --verify my_cards/ [--games N] [--seed S] [--json] # validate and play-test a folder of cards
|
|
571
|
+
exe/enjine --load game.json # resume a game saved with `s`
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
In a game: a human seat whose only option is to pass priority passes
|
|
575
|
+
automatically. A number picks an option, `b` shows the board, `o` toggles the observer view, `h [n]` shows history, `u` undoes,
|
|
576
|
+
`s FILE` saves, `c` concedes, `q` quits, `?` helps.
|
|
577
|
+
|
|
578
|
+
## 15. Errors
|
|
579
|
+
|
|
580
|
+
All inherit from `Enjine::Error`.
|
|
581
|
+
|
|
582
|
+
| Error | Raised by | Carries |
|
|
583
|
+
| --- | --- | --- |
|
|
584
|
+
| `IllegalDecision` | `submit`, `apply`, `concede` | `option`, `legal_options`, `index` (for `apply`) |
|
|
585
|
+
| `InvalidCardDefinition` | `CardDefinition.from_h`, decks, catalogs | `errors` (path-addressed) |
|
|
586
|
+
| `InvalidDeck` | `Player.new` / `Deck.from_h` on a malformed document | `errors` |
|
|
587
|
+
| `PlayerNotReady` | `Setup.new` with an illegal deck | `errors` |
|
|
588
|
+
| `InvalidSnapshot` | `Game.import` / `Setup#import` | `errors` |
|
|
589
|
+
| `CatalogConflict` | two different cards with the same id | message |
|
|
590
|
+
| `DeckLocked` | editing a deck while its player is in a game | message |
|
|
591
|
+
| `DeckBuilder::NotEnoughCards` | `DeckBuilder#build` | `details` |
|
|
592
|
+
| `Fuzz::Failure` | `Fuzz.run` | `seed`, `decisions` |
|
|
593
|
+
|
|
594
|
+
A failed call never changes a game: `submit` and `apply` validate first, and
|
|
595
|
+
`apply`/`import` are all or nothing.
|
|
596
|
+
|
|
597
|
+
## 16. Guarantees and limits
|
|
598
|
+
|
|
599
|
+
- **Rules**: two-player games by the Comprehensive Rules pinned in
|
|
600
|
+
`Enjine::RULES_VERSION` (2026-11-06). Code and tests cite rule numbers.
|
|
601
|
+
- **Scope**: the mechanics of the Standard-legal sets (see the README's Scope
|
|
602
|
+
section). Mechanics found only outside Standard (morph, madness, infect, ...)
|
|
603
|
+
are not available.
|
|
604
|
+
- **Stable surface**: everything in this guide. `game.state` and the engine's
|
|
605
|
+
internals are for tests and debugging and may change.
|
|
606
|
+
- **Determinism**: the RNG is a seeded PCG32 (`Enjine::Rng`); nothing reads the
|
|
607
|
+
clock or global randomness.
|
|
608
|
+
- **Threading**: a game is not thread-safe; use one game per thread. Games and
|
|
609
|
+
setups share only frozen data.
|
|
610
|
+
- **Not included**: matches (best of three), sideboarding between games,
|
|
611
|
+
multiplayer, and any card data. Cards you write are validated: a mechanic the
|
|
612
|
+
engine doesn't have is rejected with an error naming the unknown key and
|
|
613
|
+
listing the supported ones (`abilities[0].spell.effects[0]: unknown effect
|
|
614
|
+
"teleport" (supported: add_counters, ...)`).
|