Skip to content

Repository files navigation

Tactica

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.

Requirements

  • Swift 6.0+
  • iOS 26+ / macOS 26+ / watchOS 26+ / tvOS 26+
  • Dependencies: Foundation, GameplayKit

Build & Test

swift build
swift test
swift test --filter TestClassName/testMethodName

Quick Start

import 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()

Engagement Level — full unit shooting

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")
}

Attack Level — single weapon type

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 gates

Phase Level — inspecting individual resolution steps

Each 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")")
}

Architecture

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

Input Types

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.

Rules

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.

Simulation

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).

Results

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.

Design Decisions

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.

Test Coverage

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

About

A combat simulator for Warhammer 40k

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages