|
| 1 | +# Card Combat Engine |
| 2 | + |
| 3 | +A headless, domain-agnostic turn-based card combat engine for **Godot 4.6 / |
| 4 | +GDScript**. Handles the *logic* of a card battle — turn FSM, mana, draw, |
| 5 | +attack/defense/block, damage resolution, and pluggable AI — and exposes every |
| 6 | +game-specific rule as an injectable `Callable`. |
| 7 | + |
| 8 | +> **Core idea:** the engine is a pure logic layer. It knows nothing about your |
| 9 | +> GDD, rarities, or abilities. You inject the specifics; it runs the match. |
| 10 | + |
| 11 | +## Overview |
| 12 | + |
| 13 | +Card Combat Engine fills the gap between Godot's presentation-layer card addons |
| 14 | +(drag, animate, layout) and a working rule set. It provides the *brain*, not the |
| 15 | +visuals. |
| 16 | + |
| 17 | +**What it does:** turn FSM (`BEGIN → PREPARATION → MAIN → ATTACK → DEFENSE → |
| 18 | +RESOLVE → END`), mana ramp, hand/board/graveyard zones, creature combat, |
| 19 | +spell resolution, a structured event log, full serialization for save/resume and |
| 20 | +server-authoritative netcode. |
| 21 | + |
| 22 | +**What it doesn't do:** rendering, UI, card loaders, rarities, named abilities, |
| 23 | +or game-specific balance. All of that is injected from your game layer. |
| 24 | + |
| 25 | +**Determinism:** `CombatSession.setup(..., ai_seed)` seeds shuffles and the AI. |
| 26 | +Same seed + same starting decks = bit-for-bit identical combat — ideal for |
| 27 | +replays and authoritative networking. |
| 28 | + |
| 29 | +**Topology:** supports 1v1, 2v2, and N-sided FFA via `setup_sides(sides, teams)`. |
| 30 | + |
| 31 | +## Documentation |
| 32 | + |
| 33 | +- [Addon README](addons/card_combat/README.md) — full class reference, all |
| 34 | + injection points, signal catalog, serialization protocol, AI contract, replay |
| 35 | + pattern, and observability guide. |
| 36 | +- [Integration Guide](addons/card_combat/docs/integration_guide.md) — 12-step |
| 37 | + walkthrough: minimum setup → AbilityLibrary → custom abilities → human input → |
| 38 | + custom AI → save/resume → multi-side → networking. |
| 39 | +- [Tutorial](addons/card_combat/docs/tutorial.md) — build *Tiny Clash* from zero: |
| 40 | + headless first, then keyword abilities, custom abilities, live signals, and a |
| 41 | + human-driven turn. |
| 42 | +- [Examples](addons/card_combat/examples/) — `demo.tscn` (DummyAI vs DummyAI, |
| 43 | + full log), `custom_ai.gd` (AI contract skeleton), `ability_demo.gd` |
| 44 | + (AbilityLibrary wiring). |
| 45 | + |
| 46 | +## Architecture |
| 47 | + |
| 48 | +Three ideas carry the whole engine: |
| 49 | + |
| 50 | +1. **The engine is agnostic.** It understands decks, mana, a turn FSM, |
| 51 | + attacking, blocking and damage — nothing about your game's rarities, |
| 52 | + elements, or named abilities. |
| 53 | +2. **You inject the specifics.** Game rules enter through `Callable` hooks set |
| 54 | + on `CombatSession` before `setup()`, and through the opaque |
| 55 | + `CardData.metadata: Dictionary`. The engine never reads `metadata`; your code |
| 56 | + does. |
| 57 | +3. **You drive the FSM.** For AI-vs-AI call `auto_resolve()`. For a human player |
| 58 | + react to the `phase_changed` signal and call the action methods |
| 59 | + (`play_card`, `declare_attacker`, `declare_blocker`, …). |
| 60 | + |
| 61 | +**`CombatSession`** is the single coordinator: FSM, mana, draw, damage |
| 62 | +resolution, event log, command log, and serialization all live there by design |
| 63 | +(extracted only when the extraction adds value — see the addon README for the |
| 64 | +reasoning). |
| 65 | + |
| 66 | +**`CardData.play_kind`** controls engine behavior: `UNIT` (persists on board, |
| 67 | +combats), `EFFECT` (resolves spell effects, goes to graveyard), `PERSISTENT` |
| 68 | +(persists on board, triggers auras, does not combat). It is a dispatch enum, |
| 69 | +not a game taxonomy — game taxonomies (Weapon, Terrain, Rarity) belong in |
| 70 | +`metadata`. |
| 71 | + |
| 72 | +## Key files |
| 73 | + |
| 74 | +| File | Role | |
| 75 | +|------|------| |
| 76 | +| `addons/card_combat/combat_session.gd` | Main coordinator — FSM, actions, serialization | |
| 77 | +| `addons/card_combat/combat_deck.gd` | Card zones (hand / draw pile / board / graveyard / mana / extra) | |
| 78 | +| `addons/card_combat/card_data.gd` | Card core (id, cost, stats, `play_kind`, `metadata`) | |
| 79 | +| `addons/card_combat/card_instance.gd` | In-play card (health, modifiers, triggers) | |
| 80 | +| `addons/card_combat/combatant.gd` | Hero / participant (health, damage, heal, signals) | |
| 81 | +| `addons/card_combat/combat_ai.gd` | AI contract (5 methods every AI must implement) | |
| 82 | +| `addons/card_combat/dummy_ai.gd` | Reference AI (seeded random) | |
| 83 | +| `addons/card_combat/heuristic_ai.gd` | Stronger AI (greedy curve-filling, value trades) | |
| 84 | +| `addons/card_combat/spell_effect.gd` | Spell effect descriptor (DAMAGE/HEAL/BUFF/AOE/SUMMON/custom) | |
| 85 | +| `addons/card_combat/combat_config.gd` | Balance parameters (mana caps, hand/board limits) | |
| 86 | +| `addons/card_combat/combat_trigger_queue.gd` | Deferred trigger FIFO (QUEUED trigger mode) | |
| 87 | +| `addons/card_combat/hidden_card_stats.gd` | Declared vs hidden stats for bluffing | |
| 88 | +| `addons/card_combat/abilities/ability_library.gd` | Opt-in keyword ability handler | |
| 89 | +| `test/` | GUT 9.6 test suite — 381 cases across 20 files | |
| 90 | +| `addons/card_combat/examples/demo.tscn` | Runnable smoke-check demo | |
| 91 | +| `addons/card_combat/benchmark/combat_benchmark.gd` | Performance / leak measurement | |
| 92 | + |
| 93 | +## Injection points |
| 94 | + |
| 95 | +All hooks are `Callable` properties set on `CombatSession` **before** `setup()`. |
| 96 | +Every hook is optional — omit any you don't need. |
| 97 | + |
| 98 | +| Property | Signature | Purpose | |
| 99 | +|----------|-----------|---------| |
| 100 | +| `ability_fn` | `(inst: CardInstance, trigger: StringName, ctx: Dictionary)` | Ability trigger dispatch (called by the engine for every card event) | |
| 101 | +| `damage_fn` | `(attacker: CardInstance, defender: CardInstance) -> int` | Combat damage formula override | |
| 102 | +| `exhaust_fn` | `(owner_id: int)` | Called when a side's draw pile empties (fatigue) | |
| 103 | +| `discard_fn` | `(card: CardInstance, owner_id: int)` | Called when a card is discarded | |
| 104 | +| `attack_restriction_fn` | `(attacker: CardInstance, enemies: Array) -> Array` | Filter valid attack targets (TAUNT redirect) | |
| 105 | +| `incoming_damage_fn` | `(inst: CardInstance, amount: int, source) -> int` | Pre-damage interception (armor, prevention) | |
| 106 | +| `cost_fn` | `(card: CardData, owner_id: int) -> int` | Dynamic card cost override | |
| 107 | +| `spell_power_fn` | `(owner_id: int) -> int` | Spell damage bonus | |
| 108 | +| `aura_fn` | `(session: CombatSession)` | Called to recompute continuous modifiers (auras) | |
| 109 | + |
| 110 | +## AbilityLibrary keywords |
| 111 | + |
| 112 | +`AbilityLibrary` is an **opt-in** module that provides 14 ready-made ability |
| 113 | +keywords. Wire it once and declare abilities in `CardData.metadata["keywords"]`: |
| 114 | + |
| 115 | +```gdscript |
| 116 | +var session := CombatSession.new() |
| 117 | +var lib := AbilityLibrary.new(session) |
| 118 | +lib.wire_all() # sets ability_fn, attack_restriction_fn, incoming_damage_fn, |
| 119 | + # spell_power_fn, and aura_fn on the session |
| 120 | +``` |
| 121 | + |
| 122 | +| Keyword | Behavior | Metadata key (if tunable) | |
| 123 | +|---------|----------|---------------------------| |
| 124 | +| `CHARGE` | Can attack the turn it enters play | — | |
| 125 | +| `IMMUNITY` | Absorbs N hits completely | `immunity_hits` (default 1, -1 = infinite) | |
| 126 | +| `LIFESTEAL` | Combat damage heals the owner's hero | — | |
| 127 | +| `TAUNT` | Enemies must attack this creature while alive | — | |
| 128 | +| `THORNS` | Returns N damage to the attacker on hit | `thorns` (default 1) | |
| 129 | +| `STEALTH` | Cannot be attacked until it attacks | — | |
| 130 | +| `WINDFURY` | Can attack N times per turn | `windfury_attacks` (default 2) | |
| 131 | +| `FREEZE` | Freezes damaged creatures for N turns | `freeze_turns` (default 1) | |
| 132 | +| `ARMOR` | Reduces each incoming hit by N | `armor` (needs `incoming_damage_fn`) | |
| 133 | +| `BATTLECRY` | On play: deals N damage to a chosen target | `battlecry_damage` (default 1) | |
| 134 | +| `SPELLPOWER` | Adds N to the owner's spell damage | `spell_power` (needs `spell_power_fn`) | |
| 135 | +| `LORD` | Aura: boosts other friendly creatures +N/+M | `aura_attack`, `aura_health` (default 1) | |
| 136 | +| `OVERKILL` | Excess lethal damage transfers to the enemy hero | `overkill_factor` (default 1) | |
| 137 | +| `SPELLBURST` | Gains +N/+M permanent when owner casts a spell | `spellburst_attack`, `spellburst_health` | |
| 138 | + |
| 139 | +## Quick start |
| 140 | + |
| 141 | +```gdscript |
| 142 | +# Minimum 1v1 (AI vs AI, headless) |
| 143 | +var session := CombatSession.new() |
| 144 | +session.setup(hero0, deck0, hero1, deck1) |
| 145 | +session.start() |
| 146 | +session.auto_resolve() |
| 147 | +var result := session.get_result() # {winner_side, turn_number, hp} |
| 148 | + |
| 149 | +# With AbilityLibrary |
| 150 | +var lib := AbilityLibrary.new(session) |
| 151 | +lib.wire_all() |
| 152 | +session.ability_fn = lib.handle # already set by wire_all; shown for clarity |
| 153 | + |
| 154 | +# Human-driven turn (react to signals) |
| 155 | +session.phase_changed.connect(func(old, new): _on_phase(new)) |
| 156 | +session.start() |
| 157 | + |
| 158 | +func _on_phase(phase: int) -> void: |
| 159 | + if phase == CombatState.MAIN and session.current_side == PLAYER_SIDE: |
| 160 | + session.play_card(hand_card) |
| 161 | + session.end_main_phase() |
| 162 | +``` |
| 163 | + |
| 164 | +Full working example: `addons/card_combat/examples/demo.tscn`. |
| 165 | + |
| 166 | +## Testing |
| 167 | + |
| 168 | +```bash |
| 169 | +godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://test -ginclude_subdirs -gexit |
| 170 | +``` |
| 171 | + |
| 172 | +381 test cases (GUT 9.6) across: FSM, mana ramp, serialization/resume, |
| 173 | +determinism, AI contract, all AbilityLibrary keywords, trigger queue (INLINE vs |
| 174 | +QUEUED), multi-sided topology, command replay, and leak detection. |
| 175 | + |
| 176 | +CI runs three gates on every push: compile check, unit tests, and an ObjectDB |
| 177 | +leak gate. |
| 178 | + |
| 179 | +## License |
| 180 | + |
| 181 | +Dual-licensed: |
| 182 | + |
| 183 | +- **AGPL v3.0** — free for open-source projects. Network use (server-side) |
| 184 | + requires releasing your product's source under the AGPL. |
| 185 | +- **Commercial** — closed-source and server-side use without AGPL obligations. |
| 186 | + Contact **islasjavieralf@gmail.com**. |
0 commit comments