[GH @durandom] ## Addendum: Rules Engine Insights (from design exploration)
### How BitCraft Implements Game Rules
BitCraft doesn't have an abstract "rules engine trait" — rules are embedded directly in reducers and handler functions. Combat logic lives in `handlers/attack.rs`:
```rust
fn calculate_hit_outcome(ctx, attacker_id, defender_id, ...) -> (damage, armor, hit, crit) {
const STRENGTH_PER_DAMAGE: f32 = 15.0; // +1 damage per 15 STR
const EVASION_MULTIPLIER: f32 = 0.1;
const ARMOR_50PCT_REDUCTION: f32 = 2000.0;
// ... RPG damage formula with strength bonus, accuracy vs evasion, armor mitigation
}
```
Key patterns:
- **Constants at the top of handler files** — game balance numbers are literal constants
- **Static data from CSV** — item stats, enemy stats, recipes loaded at build time
- **`#[feature_gate("combat")]`** — custom proc macro to conditionally enable/disable game systems
### FoundryVTT D&D 5e → SpacetimeDB Mapping
The most transferable files from FoundryVTT's dnd5e system:
| FoundryVTT File | Maps To | Spellkave Equivalent |
|---|---|---|
| `config.mjs` (all D&D constants) | `static_data.rs` | Rust enums: abilities, skills, damage types, conditions |
| `activity/attack.mjs` | `handlers/attack.rs` | Attack reducer: d20 + modifier vs AC |
| `activity/save.mjs` | `handlers/save.rs` | Saving throw reducer: d20 + modifier vs DC |
| `activity/check.mjs` | `handlers/check.rs` | Ability/skill check reducer |
| `activity/damage.mjs` | `handlers/damage.rs` | Damage calculation with resistances |
| `actor/character.mjs` | `components.rs` | `AbilityScoreState`, `SkillState`, `ClassState` tables |
| `documents/combat.mjs` | `handlers/combat.rs` | Initiative, turn order (if needed in async world) |
### Adaptation Questions for Persistent World
The ADR should specifically address which D&D 5e rules need adaptation:
1. **Long Rest = "8 hours"** — in a continuous world, what triggers a long rest? Time-based? Location-based (safe haven)?
2. **Initiative order** — does turn-based combat make sense in a real-time world? Or does combat become simultaneous with action timers (like BitCraft's `AttackTimer` pattern)?
3. **Encounter balance** — CR system assumes a DM balancing encounters. In an emergent world, who balances? Does the world self-balance through consequences?
4. **Spell slots** — long rest recovery in a world that never pauses. How fast do casters recharge?
### The `#[feature_gate]` Pattern
BitCraft's feature gating is worth adopting:
```rust
#[spacetimedb::reducer]
#[feature_gate("combat")]
pub fn attack_start(ctx: &ReducerContext, request: EntityAttackRequest) -> Result<(), String> { ... }
```
This allows enabling/disabling entire game systems at runtime via a `GatedFeature` table. For Spellkave Phase 0, this means you can ship the combat system but gate it while testing social interactions first.
### Pluggability Assessment
For Phase 0-2, compile-time trait dispatch is sufficient:
```rust
// The trait exists as a design boundary
trait RulesEngine {
fn resolve_action(ctx: &ReducerContext, actor: u64, action: &Action) -> ActionResult;
}
// But only one implementation exists
struct Dnd5eRules;
impl RulesEngine for Dnd5eRules { ... }
```
Runtime swappability (selecting rules engine per-world or per-region) would require either:
- Dynamic dispatch (trait objects) — works but loses some type safety
- Separate SpacetimeDB modules per rules system — cleaner but means separate deployments
- Feature flags selecting between implementations at compile time — simplest
Recommend: compile-time selection via feature flags for now. Revisit when a second rules system is actually needed.
[GH @durandom] ## Addendum: Adaptive Combat Model (tied to Relativistic Time)
### The Problem
D&D's turn-based combat doesn't scale in a persistent world:
- AI vs AI (90% of fights): turns are meaningless — both resolve instantly
- Human vs AI: human needs think time, AI doesn't, world shouldn't freeze
- Human vs Human: both need time, but can go AFK, blocking others
- Large battles (5+ combatants): 30 turns of waiting = unplayable
### The Solution: Combat Mode Follows TimeFrame
Combat mode is not a global setting — it's determined per-encounter by who's involved. This ties directly to the Relativistic Time Model (see issue #7).
```rust
#[derive(SpacetimeType)]
pub enum CombatMode {
/// AI vs AI → entire combat resolves in one reducer call.
/// Result written as narrative to WorldEventLog.
InstantResolution,
/// 1 human in combat. Generous turn timers (60s).
/// AI acts instantly on its turn. Human gets full D&D tactical experience.
RelaxedTurns,
/// 2-3 humans. Classic D&D initiative + turns.
/// 60s timeout per turn. Disconnect = AI takes over.
ClassicTurns,
/// 5+ combatants. Turns too slow. Cooldown-based real-time.
/// D&D action economy preserved via cooldown budgets.
ActionQueues,
}
fn determine_combat_mode(combatants: &[Combatant]) -> CombatMode {
let humans = combatants.iter().filter(|c| !c.is_ai).count();
let total = combatants.len();
match (humans, total) {
(0, _) => CombatMode::InstantResolution,
(1, 2..=4) => CombatMode::RelaxedTurns,
(2..=3, _) => CombatMode::ClassicTurns,
(_, _) => CombatMode::ActionQueues,
}
}
```
### SpacetimeDB Implementation
```rust
#[spacetimedb::table(name = combat_instance, public)]
pub struct CombatInstance {
#[primary_key]
pub combat_id: u64,
pub location_entity_id: u64,
pub mode: CombatMode,
pub current_turn_entity_id: u64, // turn-based modes only
pub turn_deadline: Timestamp, // turn-based modes only
pub round: u32,
pub status: CombatStatus, // Active, Resolved, Fled
}
#[spacetimedb::table(name = combatant, public,
index(name = combat_id, btree(columns = [combat_id])))]
pub struct Combatant {
#[primary_key]
pub id: u64,
pub combat_id: u64,
pub entity_id: u64,
pub initiative: i32,
pub turn_order: u32,
pub is_ai: bool,
}
// Turn timeout enforcement (turn-based modes only)
#[spacetimedb::table(name = turn_timer,
scheduled(turn_timeout_handler, at = scheduled_at))]
pub struct TurnTimer {
#[primary_key] #[auto_inc]
pub scheduled_id: u64,
pub scheduled_at: spacetimedb::ScheduleAt,
pub combat_id: u64,
pub entity_id: u64,
}
// For ActionQueues mode: cooldown tracking (BitCraft's CombatState pattern)
#[spacetimedb::table(name = action_cooldown_state, public)]
pub struct ActionCooldownState {
#[primary_key]
pub entity_id: u64,
pub action_available_at: Timestamp,
pub bonus_action_available_at: Timestamp,
pub reaction_available: bool,
pub movement_remaining: f32,
}
```
### InstantResolution — AI vs AI Combat in Detail
The most common case. Entire combat resolves in a single reducer call:
```rust
fn resolve_instant_combat(ctx: &ReducerContext, combatants: &[u64]) {
// 1. Roll initiative for all (determines narrative order)
// 2. Simulate rounds until one side wins/flees
// 3. Apply final state (HP, death, loot) to tables
// 4. Write narrative summary to WorldEventLog:
// "Goblin patrol attacked merchant caravan on Forest Road.
// After 3 rounds, the goblins were driven off.
// 2 goblins killed, 1 guard wounded."
// Total: <100ms for a full combat
}
```
### Phase 0 Scope
Only implement:
- **InstantResolution** — AI vs AI (needed for 72h continuous world operation)
- **RelaxedTurns** — single human player encounters (needed for Phase 1 human entry)
Defer to Phase 2-3:
- ClassicTurns (multi-human party combat)
- ActionQueues (large-scale battles)
### D&D Rules Adaptations for Each Mode
| D&D Concept | InstantResolution | RelaxedTurns | ClassicTurns | ActionQueues |
|---|---|---|---|---|
| Initiative | Rolled, determines narrative | Rolled, determines turn order | Standard D&D | N/A (cooldown-based) |
| Actions per round | 1 Action + 1 Bonus + Move | Same (human gets 60s) | Same (60s timeout) | Cooldown budget per 6s |
| Reactions | Simulated (% chance) | Full D&D reactions | Full D&D reactions | Event-triggered |
| Concentration | Tracked, checked on damage | Full tracking | Full tracking | Full tracking |
| Death saves | Simulated | Full (player rolls) | Full | Full |
| Spell slots | Consumed normally | Full tracking | Full tracking | Full tracking |