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,141 @@
|
|
|
1
|
+
# Enjine — Architecture
|
|
2
|
+
|
|
3
|
+
Companion to [DESIGN.md](DESIGN.md). Describes how the code is laid out and why.
|
|
4
|
+
Rules reference: Comprehensive Rules effective **2026-11-06**.
|
|
5
|
+
|
|
6
|
+
## Folder structure
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
lib/enjine.rb # entry point; requires everything except card pools
|
|
10
|
+
lib/enjine/
|
|
11
|
+
version.rb # VERSION, RULES_VERSION (pinned CR date)
|
|
12
|
+
errors.rb # Error hierarchy
|
|
13
|
+
rng.rb # PCG32, integer state -> JSON-exportable, deterministic
|
|
14
|
+
mana.rb # mana costs, symbols, pools
|
|
15
|
+
|
|
16
|
+
# ---- static world: cards & decks (no game state) ----
|
|
17
|
+
primitive.rb # Primitive base class + Registry (every mechanic registers here)
|
|
18
|
+
mechanics.rb # helpers to build primitives from data entries
|
|
19
|
+
card_definition.rb # immutable, validated blueprint built from a data Hash
|
|
20
|
+
catalog.rb # id -> CardDefinition, conflict detection
|
|
21
|
+
deck.rb player.rb setup.rb # decklists, players, compiled pairings
|
|
22
|
+
|
|
23
|
+
mechanics/ # ONE PLACE PER MECHANIC: <category>/<mechanic>.rb, all registered primitives
|
|
24
|
+
types/ supertypes/ subtypes/ card types, supertypes, subtypes (permanent_types spell_types land saga auras_and_equipment ...)
|
|
25
|
+
keywords/ keyword abilities, CR 702 (evergreen flags crew kicker ward station alternative_costs ...)
|
|
26
|
+
abilities/ other abilities: stack_abilities (spell activated triggered loyalty), static, mana_ability,
|
|
27
|
+
replacement_effects, play_permissions, cost_changes, enters_tapped ...
|
|
28
|
+
triggers/ trigger events grouped by what happens (zone_changes combat casting life ...)
|
|
29
|
+
costs/ effects/ filters/ counters/ one file per cost, effect (damage exile counters library ...), filter, counter kind
|
|
30
|
+
*/base.rb shared bases of effects, triggers, filters, costs (abilities: stack_abilities.rb),
|
|
31
|
+
loaded first; a file requires any other mechanic it extends
|
|
32
|
+
selector.rb values.rb who an effect applies to (target / self / you / each ...); amounts and conditions
|
|
33
|
+
linked.rb card_spec.rb ... shared helpers: linked abilities, token/copy card specs, mana spent
|
|
34
|
+
|
|
35
|
+
# ---- dynamic world: a running game ----
|
|
36
|
+
game.rb # public facade: pending_decision / submit / apply / export / views
|
|
37
|
+
state.rb # State, PlayerState, GameObject (plain mutable structs)
|
|
38
|
+
engine.rb # the state machine: accessors, emit, options/submit!; one module per area below
|
|
39
|
+
engine/
|
|
40
|
+
pregame.rb priority.rb # CR 103 start and mulligans; CR 117 priority
|
|
41
|
+
turn_structure.rb # CR 500-502: advance!, steps, untap step, land plays
|
|
42
|
+
zones.rb # CR 400: create/move objects (through replacements), tokens, draw, tap
|
|
43
|
+
casting/ # CR 601/602: casting.rb (announce, targets, cancel/rewind), payment.rb,
|
|
44
|
+
# cast_choices.rb, costs.rb, target_counts.rb, alt_casting.rb, permissions.rb
|
|
45
|
+
mana_sources.rb # CR 106/605: affordability, mana abilities, mana pools
|
|
46
|
+
resolution.rb entering.rb # CR 608: resolve top of stack, fizzle; "as this enters" choices
|
|
47
|
+
triggers.rb # CR 603: matching events, pending triggers
|
|
48
|
+
combat.rb # CR 506-511: attackers, blockers, damage assignment
|
|
49
|
+
damage.rb # damage, life, counters
|
|
50
|
+
state_based_actions.rb # CR 704 and game end (CR 104)
|
|
51
|
+
characteristics.rb # CR 613 layer pipeline for P/T, continuous effects, cleanup
|
|
52
|
+
rules.rb copies.rb faces.rb ... # player rules and control; copies (CR 707); faces; sagas, phasing, speed ...
|
|
53
|
+
steps.rb # 12 step objects with turn-based actions
|
|
54
|
+
events.rb # typed, serializable events + per-viewer redaction
|
|
55
|
+
decisions/ # one class per decision kind
|
|
56
|
+
priority play_or_draw mulligan bottom discard casting(choose_target, pay_cost) combat(...)
|
|
57
|
+
actions/ # options offered at priority
|
|
58
|
+
pass play_land activate_mana_ability cast(cast, activate)
|
|
59
|
+
snapshot.rb # export/import + full consistency validation (also the fuzz invariant)
|
|
60
|
+
view.rb history.rb # per-viewer views and histories
|
|
61
|
+
|
|
62
|
+
# ---- tooling (no game logic) ----
|
|
63
|
+
docs.rb # generates docs/reference/*.md + SUPPORTED.md from the Registry
|
|
64
|
+
agents/random.rb fuzz.rb # random legal player, invariant-checking fuzzer
|
|
65
|
+
cli.rb cli/text.rb # interactive hot-seat CLI + text renderer, --validate
|
|
66
|
+
deck_builder.rb # OPT-IN random constructed decks from a card pool
|
|
67
|
+
exe/enjine
|
|
68
|
+
examples/ # ready-to-play decks and a card pool (not in the gem)
|
|
69
|
+
spec/ unit/ integration/ e2e/ support/scenario.rb fixtures/cards/ (the test and fuzz cards)
|
|
70
|
+
docs/ DESIGN.md ARCHITECTURE.md MECHANICS_COVERAGE.md SUPPORTED.md(root) reference/ guides/
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Key design decisions
|
|
74
|
+
|
|
75
|
+
### 1. Two worlds: static vs dynamic
|
|
76
|
+
- **Static** (`CardDefinition`, `Catalog`, `Deck`, `Player`, `Setup`) is validated
|
|
77
|
+
once and frozen. Shared by every game spawned from a `Setup` — no per-game cost.
|
|
78
|
+
- **Dynamic** (`State`, `GameObject`, ...) is plain mutable data. Objects refer to
|
|
79
|
+
definitions **by id** (`"forest"`), never by Ruby reference, so state is small,
|
|
80
|
+
Marshal-copyable, and serializable.
|
|
81
|
+
|
|
82
|
+
### 2. The engine is an explicit state machine
|
|
83
|
+
No Ruby call stack ever holds game progress. Turn, step, priority holder, pass
|
|
84
|
+
count, stack and the pending decision are fields on `State`. `submit(option)`
|
|
85
|
+
mutates the state one tick and `Engine#advance!` runs forward until the next
|
|
86
|
+
decision is needed. This is what makes export (including the pending decision),
|
|
87
|
+
import, transactional `apply`, `restart`, and replay possible.
|
|
88
|
+
|
|
89
|
+
### 3. Mechanics are registered primitives (composition, not inheritance)
|
|
90
|
+
```
|
|
91
|
+
Primitive (base: name, category, description, rules[], params, example, validate/build)
|
|
92
|
+
├─ category :type -> Mechanics::Types::Land, (Creature, Instant, ... later)
|
|
93
|
+
├─ category :supertype -> Basic
|
|
94
|
+
├─ category :subtype -> Plains/Island/Swamp/Mountain/Forest
|
|
95
|
+
├─ category :ability -> ManaAbility (Triggered, Static, Activated ... later)
|
|
96
|
+
├─ category :cost -> Tap (Mana, Sacrifice ... later)
|
|
97
|
+
└─ category :effect -> (DealDamage, AddCounters ... milestone 2)
|
|
98
|
+
```
|
|
99
|
+
A card is a *set* of type primitives plus a list of ability primitives. A card
|
|
100
|
+
data block naming anything not in the Registry fails with
|
|
101
|
+
`InvalidCardDefinition` and a full list of path-addressed errors. Docs and
|
|
102
|
+
`SUPPORTED.md` are generated from the same Registry, so they cannot drift.
|
|
103
|
+
|
|
104
|
+
### 4. Decisions and actions are small classes with one job each
|
|
105
|
+
- `Decisions::*`: a kind of pending decision (`priority`, `mulligan`, `bottom`,
|
|
106
|
+
`discard`, `play_or_draw`). Each one has `options(engine, pending)` and
|
|
107
|
+
`submit(engine, pending, option)`.
|
|
108
|
+
- `Actions::*`: what you can do at priority (`pass`, `play_land`,
|
|
109
|
+
`activate_mana`, and later `cast` / `activate`). Priority options are the union
|
|
110
|
+
of every registered action's options. Adding casting means adding one Action class.
|
|
111
|
+
- Options are plain frozen hashes (`{type: "play_land", object: "forest#12"}`), so
|
|
112
|
+
they are JSON-able and comparable with `==`.
|
|
113
|
+
|
|
114
|
+
### 5. Events are the single source of change
|
|
115
|
+
Every mutation emits a typed event (`Data` classes). Events feed history, the CLI
|
|
116
|
+
log, subscribers and test assertions, and triggered abilities later. Each event
|
|
117
|
+
knows how to redact itself for a viewer: a library→hand move shows only "drew
|
|
118
|
+
a card" to the opponent.
|
|
119
|
+
|
|
120
|
+
### 6. Determinism and IDs
|
|
121
|
+
- PCG32 RNG, with its state stored as an integer in the snapshot.
|
|
122
|
+
- Object id = `<definition_id>#<n>` from a per-game counter. A card gets a new id
|
|
123
|
+
on every zone change (CR 400.7).
|
|
124
|
+
- `Setup` + seed reproduces a game exactly, and `restart` uses the same path.
|
|
125
|
+
|
|
126
|
+
### 6a. Cards outside the game
|
|
127
|
+
A game doesn't know about a match, so a deck's optional `outside` list stands for the
|
|
128
|
+
cards its player owns outside the game (a constructed sideboard, CR 400.11a, 100.4a).
|
|
129
|
+
At setup each player gets `PlayerState#outside`, a list of definition ids. These are not
|
|
130
|
+
objects, because outside the game isn't a zone and the cards have no ids yet
|
|
131
|
+
(CR 400.11). `State#outside_lists` keeps the starting lists for `restart`. Both
|
|
132
|
+
round-trip through snapshots, which check every id against the catalog. Legal actions
|
|
133
|
+
name such a card `outside:<card id>`, and legality checks use a detached stand-in
|
|
134
|
+
object that is never stored. The real object is created only as the card enters
|
|
135
|
+
the stack or the battlefield (`Engine#bring_in_from_outside!`), owned by that player
|
|
136
|
+
(CR 108.3). A cancelled cast puts the id back in the list.
|
|
137
|
+
|
|
138
|
+
### 7. Atomicity
|
|
139
|
+
`submit` validates against the legal options before touching anything. `apply`
|
|
140
|
+
and `import` work on a Marshal copy and swap it in only on success. Events are
|
|
141
|
+
buffered and delivered to subscribers only after success.
|
data/docs/DESIGN.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Enjine — Design
|
|
2
|
+
|
|
3
|
+
Enjine is a Ruby gem that runs two-player Magic: The Gathering games with strict
|
|
4
|
+
Comprehensive Rules (CR) fidelity. It is pure gameplay infrastructure: no AI, no
|
|
5
|
+
heuristics, no "smartness". Callers (a text CLI today, reinforcement-learning
|
|
6
|
+
agents in a separate project later) drive it through an atomic decision API.
|
|
7
|
+
|
|
8
|
+
## 1. Scope
|
|
9
|
+
|
|
10
|
+
- **Standard.** The target is every mechanic on the cards of the current
|
|
11
|
+
Standard-legal sets, plus the core rules (CR 1–6) every game needs. Mechanics
|
|
12
|
+
found only on cards outside Standard, multiplayer and casual variants are out of
|
|
13
|
+
scope until a Standard card needs them. New Standard sets add mechanics; rotated
|
|
14
|
+
sets keep theirs.
|
|
15
|
+
- Two-player constructed: 20 life, deck ≥ 60 cards, max 4 copies of a card
|
|
16
|
+
(basic lands and "any number" cards exempt). No sideboard tracking.
|
|
17
|
+
- Single games, start to finish. No best-of-3 / match object.
|
|
18
|
+
- The engine knows **no cards**: every card arrives as data, in a deck document or a
|
|
19
|
+
`DeckBuilder` pool. The test and fuzz cards live in `spec/fixtures/cards/`.
|
|
20
|
+
- Pinned to a dated CR version. Code and tests cite rule numbers (`# CR 704.5a`).
|
|
21
|
+
|
|
22
|
+
## 2. Cards
|
|
23
|
+
|
|
24
|
+
- **Data-first.** Card definitions are pure data (JSON / Hash). No Ruby card code.
|
|
25
|
+
- **Primitive registry.** Abilities are composed of named primitives: effects,
|
|
26
|
+
costs, targets/filters, triggers, conditions, amounts, keywords, continuous
|
|
27
|
+
effects, replacements. Each primitive declares: name, parameter schema,
|
|
28
|
+
description, JSON example, CR references.
|
|
29
|
+
- **Validation.** Loading validates against primitive schemas. Unknown/invalid
|
|
30
|
+
pieces raise `InvalidCardDefinition` listing *all* errors with paths
|
|
31
|
+
(`abilities[0].effect[1].deal_damage.amount`).
|
|
32
|
+
- **Composition for card types.** A card has a *set* of types; each type is a
|
|
33
|
+
rule module (timing, destination zone, characteristics). Keywords are modules.
|
|
34
|
+
Inheritance only for engine abstractions (`Ability`, `Effect`, `Cost`,
|
|
35
|
+
`Decision`, `Event`, `Zone`).
|
|
36
|
+
- **Extension.** A new mechanic = a new primitive class registered by name.
|
|
37
|
+
- **IDs.** Definition id = canonical slug of the unique card name
|
|
38
|
+
(`"Lightning Bolt"` → `lightning_bolt`).
|
|
39
|
+
|
|
40
|
+
## 3. Players, Setup, Game
|
|
41
|
+
|
|
42
|
+
- `Player` = name + deck document (card definitions + decklist). `ready?` only
|
|
43
|
+
when the deck is valid. Between games the deck can be edited freely
|
|
44
|
+
(swap/add/remove, any number of cards) and is re-validated. Editing while
|
|
45
|
+
seated in an unfinished game raises `DeckLocked`.
|
|
46
|
+
- `Setup` = frozen, compiled once from two ready players: validated catalog
|
|
47
|
+
(union of both players' definitions; differing definitions under the same
|
|
48
|
+
name → `CatalogConflict`), compiled decks. All games spawned from a Setup
|
|
49
|
+
share compiled definitions in memory.
|
|
50
|
+
- `setup.new_game(seed:)` — cheap new game (new shuffle, die roll, mulligans).
|
|
51
|
+
- `game.restart(seed: nil)` — back to the opening; same seed = identical game.
|
|
52
|
+
- `Setup + seed` fully reproduces a game; training stores seeds, not states.
|
|
53
|
+
- The engine runs setup itself: shuffle, die roll, play/draw choice, London
|
|
54
|
+
mulligan, opening hands.
|
|
55
|
+
|
|
56
|
+
## 4. Decision API
|
|
57
|
+
|
|
58
|
+
- `game.pending_decision` → `{player, kind, options}`; `game.submit(option)`.
|
|
59
|
+
- **No auto-skip.** Every priority point is an explicit decision.
|
|
60
|
+
- **Atomic.** Each decision is one small choice: cast/activate, choose target,
|
|
61
|
+
tap a mana source (manual mana — one source at a time), declare attackers,
|
|
62
|
+
declare blockers, order/assign damage, choose X, choose mode, mulligan, etc.
|
|
63
|
+
- **Cancel** is allowed until all costs are paid; it rolls back everything done
|
|
64
|
+
during that cast/activation (card back to hand, sources tapped during the
|
|
65
|
+
payment untap; mana that was in the pool before stays). Once costs are paid
|
|
66
|
+
the spell is cast and cannot be undone.
|
|
67
|
+
- Invalid submission raises `IllegalDecision` (with the legal options); state is
|
|
68
|
+
never partially mutated.
|
|
69
|
+
|
|
70
|
+
## 5. State, Events, History, Views
|
|
71
|
+
|
|
72
|
+
- Mutable state of compact structs indexed by id; seeded RNG; fully deterministic.
|
|
73
|
+
- **Object ids**: `<definition_id>#<n>` from a per-game counter. A new id on every
|
|
74
|
+
zone change (CR 400.7).
|
|
75
|
+
- **Events**: every change is a typed, serializable event (`DamageDealt`,
|
|
76
|
+
`ZoneChanged`, `LifeChanged`, `CounterAdded`, `StepBegan`, ...). Events drive
|
|
77
|
+
triggers, history, the CLI log and test assertions.
|
|
78
|
+
- **History**: ordered log of (turn, step, player, decision, resulting events).
|
|
79
|
+
`history_for(player)` hides what that player cannot know; the observer history
|
|
80
|
+
shows everything.
|
|
81
|
+
- **Views**:
|
|
82
|
+
- `view_for(player)` — that player's legal knowledge (opponent hand and
|
|
83
|
+
libraries as counts).
|
|
84
|
+
- `view_for(:observer)` — omniscient: hands, face-down cards, library order.
|
|
85
|
+
- `state` — raw internals, for tests/debugging.
|
|
86
|
+
|
|
87
|
+
## 6. Export / Import / Fast-forward
|
|
88
|
+
|
|
89
|
+
- JSON snapshot: full state, RNG state, pending decision; versioned schema.
|
|
90
|
+
- Replay: seed + decision log.
|
|
91
|
+
- `game.apply([decisions])` and snapshot import are **transactional**: all or
|
|
92
|
+
nothing; on failure the game is restored and the error names the failing
|
|
93
|
+
index and reason.
|
|
94
|
+
- Deck rules apply to new games only; an imported state must be internally
|
|
95
|
+
consistent (valid zones, real objects, legal step, matching pending decision).
|
|
96
|
+
|
|
97
|
+
## 7. Errors
|
|
98
|
+
|
|
99
|
+
`Enjine::Error` → `IllegalDecision`, `InvalidCardDefinition`, `InvalidDeck`,
|
|
100
|
+
`InvalidSnapshot`, `CatalogConflict`, `DeckLocked`.
|
|
101
|
+
|
|
102
|
+
## 8. Testing
|
|
103
|
+
|
|
104
|
+
Every mechanic ships with three levels:
|
|
105
|
+
|
|
106
|
+
1. **Unit** — the primitive in isolation (schema, validation, effect on a
|
|
107
|
+
minimal state).
|
|
108
|
+
2. **Integration** — scenario builder imports a board position, asserts legal
|
|
109
|
+
options, submits a decision, asserts resulting state and events.
|
|
110
|
+
3. **End-to-end** — full games from two 60-card decks via `Setup`, scripted and
|
|
111
|
+
fuzzed (random legal options), with invariant checks after every step.
|
|
112
|
+
|
|
113
|
+
RSpec. CI (GitHub Actions): RSpec, RuboCop, SimpleCov ≥ 95% line + branch,
|
|
114
|
+
docs drift check, short fuzz run.
|
|
115
|
+
|
|
116
|
+
## 9. Docs
|
|
117
|
+
|
|
118
|
+
- Generated from the primitive registry: `docs/reference/*.md`, `SUPPORTED.md`.
|
|
119
|
+
CI fails on drift.
|
|
120
|
+
- Hand-written guides in `docs/guides/`: game lifecycle, decision API, card data
|
|
121
|
+
format, adding a mechanic.
|
|
122
|
+
|
|
123
|
+
## 10. Tooling & Performance
|
|
124
|
+
|
|
125
|
+
- Ruby 4.0.x with YJIT. Zero runtime dependencies.
|
|
126
|
+
- Dev: rspec, rubocop(-rspec), simplecov, benchmark-ips, stackprof,
|
|
127
|
+
memory_profiler. Benchmarks/profiling are rake tasks, not CI gates.
|
|
128
|
+
- `exe/enjine`: text CLI, random decks from the pool, seats `human` / `random`.
|
|
129
|
+
|
|
130
|
+
## 11. Milestones
|
|
131
|
+
|
|
132
|
+
1. **Skeleton** — setup, zones, turn structure, priority, mulligan, lands, manual
|
|
133
|
+
mana, vanilla creatures, combat, SBAs, win/lose, export/import, CLI, fuzzer.
|
|
134
|
+
2. **Stack** — instants/sorceries, targeting, countering, activated & triggered
|
|
135
|
+
abilities, cancel.
|
|
136
|
+
3. **Continuous effects** — layers (CR 613), static abilities,
|
|
137
|
+
replacement/prevention, counters, auras, equipment.
|
|
138
|
+
4. **Keywords & planeswalkers**.
|
|
139
|
+
5. **Long tail** — copy, control change, exotic mechanics, larger pool.
|