Warhammer 40K 10th Edition combat engine. Runs Monte Carlo simulations to calculate expected damage, kill probability, and wound distributions for single and multi-weapon attack sequences.
- Swift 6.0+
- iOS 26+ / macOS 26+ / watchOS 26+ / tvOS 26+
- Dependencies: Foundation, GameplayKit
swift build
swift test
swift test --filter TestClassName/testMethodNameimport Tactica
// -- Setup --
// 5 Intercessors shooting at 10 Guardsmen
let guardsmen = CalculatorProfile(count: 10, toughness: 3, wounds: 1, save: 5)
let boltRifle = CalculatorWeapon(
name: "Bolt Rifle", count: 5, range: 24, ballisticSkill: 3,
strength: 4, attacks: 2, damage: 1, armourPenetration: 1,
rules: StandardRules.fromWeaponAbilities([.assault, .heavy])
)
let boltPistol = CalculatorWeapon(
name: "Bolt Pistol", count: 5, range: 12, ballisticSkill: 3,
strength: 4, attacks: 1, damage: 1, armourPenetration: 0,
rules: []
)
// Engagement-level rules (detachment abilities, stratagems, etc.)
let oathOfMoment = CombatRule(
name: "Oath of Moment",
condition: .always,
effects: [.rerollHits(.allFailures)]
)
let context = EngagementContext(remainedStationary: true, distance: 12)
let calculator = AttackCalculator()An Engagement is a unit firing all its weapons at a target. Each weapon resolves sequentially through the full attack pipeline, with target casualties tracked between weapons.
let engagement = Engagement(
weapons: [boltRifle, boltPistol],
target: guardsmen,
rules: [oathOfMoment],
context: context
)
// Monte Carlo distribution (async, default 10k iterations)
let distribution = await calculator.calculateDamageDistribution(for: engagement)
// distribution.mean, distribution.median, distribution.standardDeviation
// Fast analytical estimate (synchronous)
let expected = calculator.calculateExpectedDamage(for: engagement)
// Detailed simulation with per-weapon results and target state tracking
let simulation = calculator.simulateEngagement(engagement)
for weaponResult in simulation.weaponResults {
print("\(weaponResult.weapon.name): \(weaponResult.simulation.totalDamage) damage")
}
for snapshot in simulation.targetSnapshots {
print("\(snapshot.afterWeaponName): \(snapshot.modelsRemaining) models remaining")
}An Attack is one weapon type fired at a target — e.g. "5 bolt rifles into guardsmen." Resolves the full pipeline: Hit → Wound → Save → Damage → FNP.
let attack = calculator.simulateAttack(
weapon: boltRifle,
target: guardsmen,
context: context,
rules: [oathOfMoment]
)
print(attack.totalAttacks) // number of attack dice rolled
print(attack.hitRolls.count) // includes sustained hits
print(attack.totalDamage) // wounds that got through all gatesEach attack records every roll per phase for UI drill-down.
let attack = calculator.simulateAttack(
weapon: boltRifle, target: guardsmen,
context: context, rules: [oathOfMoment]
)
// Hit phase
for hit in attack.hitRolls {
print("Rolled \(hit.roll) vs \(hit.modifiedSkill)+: \(hit.isHit ? "hit" : "miss")")
}
// Wound phase
for wound in attack.woundRolls {
print("Rolled \(wound.roll) vs \(wound.target)+: \(wound.isWound ? "wound" : "fail")")
}
// Save phase
for save in attack.saveRolls {
print("Rolled \(save.roll) vs \(save.modifiedSave)+: \(save.isSaved ? "saved" : "failed")")
}
// Damage phase
for damage in attack.damageRolls {
print("\(damage.damage) wounds through\(damage.isMortalWound ? " (mortal)" : "")")
}
// FNP phase (if active)
for fnp in attack.fnpRolls {
print("FNP \(fnp.roll) vs \(fnp.threshold)+: \(fnp.saved ? "ignored" : "wound lost")")
}The simulation is structured in three layers, each independently simulatable:
| Layer | Concept | Simulates |
|---|---|---|
| Engagement | Unit fires all weapons at target | Orchestrates attacks, tracks target casualties between weapons |
| Attack | One weapon type (e.g. 8x bolter) | Stateless pipeline: Hit → Wound → Save → Damage → FNP |
| Phase | Single resolver step | HitResolver, WoundResolver, SaveResolver, DamageResolver, FNPResolver |
| Type | Purpose |
|---|---|
Engagement |
User-facing input. Bundles weapon(s), target, rules, and context. |
CalculatorProfile |
Model stats: count, toughness, wounds, save. |
CalculatorWeapon |
Weapon stats: skill, strength, attacks, damage, armourPenetration, rules. Ranged and melee initialisers. |
EngagementContext |
Situational game state: movement, range, cover, keywords. |
| Type | Purpose |
|---|---|
CombatRule |
Named pairing of a CombatCondition with one or more CombatEffect values. The unit of composition for all weapon abilities, unit abilities, and detachment rules. |
CombatCondition |
When an effect activates: .always, .remainedStationary, .onCriticalHit, .onCriticalWound, .targetHasKeyword, etc. |
CombatEffect |
Atomic modification to the attack sequence: .lethalHits, .sustainedHits(DiceExpression), .feelNoPain(Int), .rerollHits(RerollPolicy), etc. |
ResolvedModifiers |
Flat struct produced per-weapon from ResolvedModifiers.resolve(rules:context:). Enforces 10th Edition capping (hit/wound mods ±1, cover tracking). |
StandardRules |
Catalogue mapping WeaponAbility enum cases to CombatRule representations. |
| Type | Purpose |
|---|---|
AttackCalculator |
Actor with three calculation modes: analytical (sync), Monte Carlo (async), and detailed simulation (sync). |
TargetState |
Mutable model-level wound tracker. Initialised from target profile, updated between weapons during engagement simulation. |
EngagementSimulation |
Per-weapon results and target state snapshots for casualty progression. |
WeaponSimulationResult |
Per-weapon breakdown: dice rolls + damage allocations. |
TargetStateSnapshot |
Target unit state after each weapon fires. |
DamageAllocation |
Per-damage-roll allocation to target models (effective damage, overkill, model destroyed). |
| Type | Purpose |
|---|---|
AttackSimulation |
Complete attack output: per-phase roll arrays and aggregate totalDamage. |
HitResult, WoundResult, SaveResult, DamageResult, FNPRoll |
Per-phase roll results, all conforming to DiceRollResultProtocol. |
DamageDistribution |
Full probability distribution with percentile access. |
AttackResult |
Summary: expected damage, hit/wound/save probabilities, kill rate. |
Deterministic rule IDs. CombatRule.id is a UUID derived from a content hash of name + source + condition + effects. Identical rules always produce the same identifier regardless of when or where they are constructed. confidence is excluded from equality and hashing so AI-inferred rules with the same content as a high-confidence rule are treated as equal.
Roll-time trigger evaluation. Conditions like .onCriticalHit and .onCriticalWound always pass the pre-resolution filter so their effects are collected into ResolvedModifiers. They are then evaluated a second time against the actual roll value inside the simulation loop.
Cover tracking separation. Cover-sourced save bonuses are stored separately from non-cover save bonuses so that .ignoresCover only cancels improvements that came from cover. It does not interact with other save modifiers.
Flat modifier struct. ResolvedModifiers is a plain struct populated once before the simulation loop begins. The hot loop reads struct fields directly — there is no collection iteration on each roll.
The test suite uses Swift Testing (@Suite, @Test, #expect). Coverage includes:
- Core engine:
AttackCalculator,DiceExpression,ResolvedModifiers,StandardRules - Weapon keyword integration tests
- 23 faction-specific test files covering army rules, detachment abilities, stratagems, enhancements, and unit abilities for every WH40K faction with detachments