all features implemented

This commit is contained in:
Rory Hinnen
2026-09-07 15:27:33 -05:00
parent ac4d4a670a
commit 2a6130ba8e
64 changed files with 691 additions and 340 deletions
-312
View File
@@ -1,312 +0,0 @@
//
// Dice.swift
// Bunch O' Dice
//
// Created by Rory Hinnen on 22/2/20.
//
import Foundation
struct Dice {
// MARK: Private
private var d6: Int {
get {
return Int.random(in: 1...6)
}
}
// MARK: Enums
enum DiceType: Int, CaseIterable{
case one = 1
case two = 2
case three = 3
case four = 4
case five = 5
case six = 6
}
enum RollModifiers: Int {
case negative = -1
case none = 0
case positive = 1
func add() -> RollModifiers {
switch self {
case .negative:
return RollModifiers.none
case .none:
return RollModifiers.positive
case .positive:
return RollModifiers.positive
}
}
func remove() -> RollModifiers {
switch self {
case .negative:
return RollModifiers.negative
case .none:
return RollModifiers.negative
case .positive:
return RollModifiers.none
}
}
}
// MARK: Variables
var one = 0
var two = 0
var three = 0
var four = 0
var five = 0
var six = 0
// MARK: Roll controllers
// clear and roll the die
mutating func roll(howMany: Int) {
clear()
var y = howMany
while y > 0 {
switch d6 {
case 1:
one += 1
case 2:
two += 1
case 3:
three += 1
case 4:
four += 1
case 5:
five += 1
default:
six += 1
}
y -= 1
}
}
// calculate basic roll
func total(cutoff: Int, modifier: Dice.RollModifiers) -> Int {
var total = six
switch modifier {
case .none:
switch cutoff {
case 5:
total += five
case 4:
total += four + five
case 3:
total += three + four + five
case 2:
total += two + three + four + five
default:
break
}
case .negative:
switch cutoff {
case 4:
total += five
case 3:
total += four + five
case 2:
total += three + four + five
case 1:
total += two + three + four + five
default:
break
}
case .positive:
switch cutoff {
case 6:
total += five
case 5:
total += four + five
case 4:
total += three + four + five
case 3, 2:
total += two + three + four + five
default:
break
}
}
return total
}
// Calculate different rolls
func hit(die: Int, cutoff: Int, modifier: Int) -> Int {
return total(cutoff: cutoff, modifier: Dice.RollModifiers(rawValue: modifier) ?? .none)
}
func wound(die: Int, mortal: Bool, cutoff: Int, modifier: Int) -> Int {
if mortal {
return total(cutoff: 2, modifier: Dice.RollModifiers(rawValue: modifier) ?? .none)
} else {
return total(cutoff: cutoff, modifier: Dice.RollModifiers(rawValue: modifier) ?? .none)
}
}
func armour(die: Int, invulnerable: Bool, save: Int, armourPenetration: Int) -> Int {
var total = 0
switch invulnerable ? save : (save + armourPenetration) {
case 7...:
total = six + five + four + three + two + one
case 6:
total = five + four + three + two + one
case 5:
total = four + three + two + one
case 4:
total = three + two + one
case 3:
total = two + one
default:
total = one
}
return total
}
// MARK: roll utilities
// Utility functions
mutating func clear() {
one = 0
two = 0
three = 0
four = 0
five = 0
six = 0
}
mutating func add(pool: Dice) {
one += pool.one
two += pool.two
three += pool.three
four += pool.four
five += pool.five
six += pool.six
}
func value(which: DiceType) -> Int {
switch which {
case .one:
return one
case .two:
return two
case .three:
return three
case .four:
return four
case .five:
return five
case .six:
return six
}
}
// MARK: Extra
mutating func increment(die: DiceType) {
switch die {
case .one:
one += 1
case .two:
two += 1
case .three:
three += 1
case .four:
four += 1
case .five:
five += 1
case .six:
six += 1
}
}
mutating func decrement(die: DiceType) {
switch die {
case .one:
if one > 0 {
one -= 1
}
case .two:
if two > 0 {
two -= 1
}
case .three:
if three > 0 {
three -= 1
}
case .four:
if four > 0 {
four -= 1
}
case .five:
if five > 0 {
five -= 1
}
case .six:
if six > 0 {
six -= 1
}
}
}
mutating func reroll(dice: DiceType) {
var rerolls = Dice()
switch dice {
case .one:
rerolls.roll(howMany: one)
one = 0
self.add(pool: rerolls)
case .two:
rerolls.roll(howMany: two)
two = 0
self.add(pool: rerolls)
case .three:
rerolls.roll(howMany: three)
three = 0
self.add(pool: rerolls)
case .four:
rerolls.roll(howMany: four)
four = 0
self.add(pool: rerolls)
case .five:
rerolls.roll(howMany: five)
five = 0
self.add(pool: rerolls)
case .six:
rerolls.roll(howMany: six)
six = 0
self.add(pool: rerolls)
}
}
mutating func rerollBelow(cutoff: Int) {
var count = 0
for faceValue in 1..<cutoff {
guard let face = DiceType(rawValue: faceValue) else { continue }
count += value(which: face)
switch face {
case .one: one = 0
case .two: two = 0
case .three: three = 0
case .four: four = 0
case .five: five = 0
case .six: six = 0
}
}
var rerolls = Dice()
rerolls.roll(howMany: count)
add(pool: rerolls)
}
mutating func blowup(dice: DiceType) {
var rolls = Dice()
if dice == .six {
rolls.roll(howMany: six)
self.add(pool: rolls)
} else if dice == .five {
rolls.roll(howMany: six + five)
self.add(pool: rolls)
}
}
}

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 547 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 547 KiB

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

@@ -121,12 +121,32 @@ struct MainView: View {
.font(.system(size: controlSize)) .font(.system(size: controlSize))
} }
.padding(.horizontal) .padding(.horizontal)
if current.mortalWoundsTotal > 0 {
HStack {
Text("Mortal wounds: \(current.mortalWoundsTotal) (bypassed saves)")
.fontWeight(.semibold)
.foregroundColor(.orange)
Spacer()
}
.font(.system(size: 18))
.padding(.horizontal)
}
} }
.padding() .padding()
} }
@ViewBuilder func woundCalculator(controlSize: Double) -> some View { @ViewBuilder func woundCalculator(controlSize: Double) -> some View {
VStack { VStack {
if current.autoWoundsCarried > 0 {
HStack {
Text("Auto-wounds carried: \(current.autoWoundsCarried) (skip wound roll)")
.fontWeight(.semibold)
.foregroundColor(.orange)
Spacer()
}
.font(.system(size: 18))
.padding(.horizontal)
}
HStack(alignment: .top) { HStack(alignment: .top) {
Spacer() Spacer()
VStack { VStack {
@@ -197,10 +217,25 @@ struct MainView: View {
.font(.system(size: controlSize)) .font(.system(size: controlSize))
} }
.padding(.horizontal) .padding(.horizontal)
VStack(spacing: 8) {
HStack {
Toggle("Devastating Wounds", isOn: $current.devastatingWounds)
}
if current.devastatingWounds {
HStack {
Text("Mortals (6s): \(current.critWoundCount)")
.fontWeight(.semibold)
.foregroundColor(.orange)
Spacer()
}
}
}
.font(.system(size: 18))
.padding(.horizontal)
} }
.padding() .padding()
} }
@ViewBuilder func hitCalculator(controlSize: Double) -> some View { @ViewBuilder func hitCalculator(controlSize: Double) -> some View {
VStack { VStack {
HStack(alignment: .top) { HStack(alignment: .top) {
@@ -270,10 +305,65 @@ struct MainView: View {
.opacity(current.torrent ? 0.4 : 1.0) .opacity(current.torrent ? 0.4 : 1.0)
} }
.padding(.horizontal) .padding(.horizontal)
VStack(spacing: 8) {
HStack {
Toggle("Lethal Hits", isOn: $current.lethalHits)
Spacer()
Toggle("Sust. Hits", isOn: $current.sustainedHits)
}
if current.sustainedHits {
HStack {
Text("X:")
Spacer()
Text("▲")
.gesture(TapGesture().onEnded {
if current.sustainedHitsX < 6 { current.sustainedHitsX += 1 }
})
Text(String(current.sustainedHitsX))
.frame(minWidth: 30, alignment: .center)
Text("▼")
.gesture(TapGesture().onEnded {
if current.sustainedHitsX > 1 { current.sustainedHitsX -= 1 }
})
}
}
HStack {
Toggle("Anti-X", isOn: $current.antiX)
if current.antiX {
Spacer()
Text("▲")
.gesture(TapGesture().onEnded {
if current.antiXThreshold < 6 { current.antiXThreshold += 1 }
})
Text("\(current.antiXThreshold)+")
.frame(minWidth: 34, alignment: .center)
Text("▼")
.gesture(TapGesture().onEnded {
if current.antiXThreshold > 2 { current.antiXThreshold -= 1 }
})
}
}
if current.lethalHits || current.sustainedHits || current.antiX {
HStack {
if current.lethalHits || current.sustainedHits {
Text("Crits (6s): \(current.critHitCount)")
.fontWeight(.semibold)
}
Spacer()
if current.antiX {
Text("Anti-X (\(current.antiXThreshold)+): \(current.antiXCount)")
.fontWeight(.semibold)
}
}
.foregroundColor(.orange)
}
}
.font(.system(size: 18))
.padding(.horizontal)
} }
.padding() .padding()
} }
//MARK: die views //MARK: die views
@ViewBuilder func die(whichDie: Dice.DiceType) -> some View { @ViewBuilder func die(whichDie: Dice.DiceType) -> some View {
let size = 22.0 let size = 22.0
@@ -282,13 +372,13 @@ struct MainView: View {
let displayColorArmorText = (whichDie.rawValue < current.cutoff.rawValue) ? Color.red : Color.primary let displayColorArmorText = (whichDie.rawValue < current.cutoff.rawValue) ? Color.red : Color.primary
VStack(spacing: 2) { VStack(spacing: 2) {
Image(systemName: "die.face." + String(whichDie.rawValue) + ".fill")
.foregroundColor(current.state == .armour ?
displayColorArmor : displayColorHit)
Text(String(current.dice.value(which: whichDie))) Text(String(current.dice.value(which: whichDie)))
.foregroundColor(current.state == .armour ? .foregroundColor(current.state == .armour ?
displayColorArmorText : displayColorHit) displayColorArmorText : displayColorHit)
.frame(minWidth: 30, alignment: .center) .frame(minWidth: 30, alignment: .center)
Image(systemName: "die.face." + String(whichDie.rawValue) + ".fill")
.foregroundColor(current.state == .armour ?
displayColorArmor : displayColorHit)
} }
.font(.system(size: size)) .font(.system(size: size))
.padding(4) .padding(4)
@@ -305,7 +395,7 @@ struct MainView: View {
@ViewBuilder func diePool() -> some View { @ViewBuilder func diePool() -> some View {
HStack(spacing: 0) { HStack(spacing: 0) {
ForEach(Dice.DiceType.allCases.reversed(), id: \.rawValue) { i in ForEach(Dice.DiceType.allCases, id: \.rawValue) { i in
die(whichDie: i) die(whichDie: i)
} }
} }
+1
View File
@@ -0,0 +1 @@
@_exported import DieHammerEngine
@@ -154,7 +154,17 @@ class Roll: ObservableObject {
@Published var blowupUsed = false @Published var blowupUsed = false
@Published var torrent = false @Published var torrent = false
@Published var psychic = false @Published var psychic = false
@Published var sustainedHits = false
@Published var sustainedHitsX = 1
@Published var lethalHits = false
@Published var devastatingWounds = false
@Published var antiX = false
@Published var antiXThreshold = 6
@Published var autoWoundsCarried = 0
@Published var mortalWoundsTotal = 0
// calculated variables // calculated variables
var totalDigits: [Int] { var totalDigits: [Int] {
get { get {
@@ -220,22 +230,46 @@ class Roll: ObservableObject {
get { get {
if torrent { return total } if torrent { return total }
let mod = psychic ? Dice.RollModifiers.none.rawValue : modifier.rawValue let mod = psychic ? Dice.RollModifiers.none.rawValue : modifier.rawValue
return self.dice.hit(die: total, cutoff: self.hitCutoff.rawValue, modifier: mod) return self.dice.hit(cutoff: self.hitCutoff.rawValue, modifier: mod)
} }
} }
var wound: Int { var wound: Int {
get { get {
return self.dice.wound(die: total, mortal: self.mortal, cutoff: self.woundCutoff, modifier: woundModifier.rawValue) let cutoff = self.mortal ? 2 : self.woundCutoff
return self.dice.wound(cutoff: cutoff, modifier: woundModifier.rawValue)
} }
} }
var save: Int { var save: Int {
get { get {
return self.dice.armour(die: total, invulnerable: self.invulnerable, save: self.armourSave, armourPenetration: self.armourPenetration) return self.dice.armour(save: self.armourSave, armourPenetration: self.armourPenetration, invulnerable: self.invulnerable)
} }
} }
var critHitCount: Int { dice.six }
var antiXCount: Int {
guard antiX else { return 0 }
var count = 0
for face in antiXThreshold...6 {
if let dt = Dice.DiceType(rawValue: face) { count += dice.value(which: dt) }
}
return count
}
var critWoundCount: Int { devastatingWounds ? dice.six : 0 }
// Deduplicated count of hit-phase crits that auto-wound (Lethal Hits and/or Anti-X).
// Anti-X is always the superset when its threshold <= 6, so it takes precedence.
// Gated off during Torrent since there are no hit dice to count natural rolls on.
var autoWoundCrits: Int {
guard !torrent else { return 0 }
if antiX { return antiXCount }
if lethalHits { return dice.six }
return 0
}
var currentRerollMode: RerollMode { var currentRerollMode: RerollMode {
switch state { switch state {
case .hit: return hitRerollMode case .hit: return hitRerollMode
@@ -336,11 +370,17 @@ class Roll: ObservableObject {
} }
switch self.state { switch self.state {
case .hit: case .hit:
self.total = hit let crits = autoWoundCrits
let sustained = (sustainedHits && !torrent) ? dice.six * sustainedHitsX : 0
autoWoundsCarried = min(crits, hit)
self.total = max(0, hit - autoWoundsCarried) + sustained
self.state = .wound self.state = .wound
woundHasRerolled = false woundHasRerolled = false
case .wound: case .wound:
self.total = wound let mortals = critWoundCount
mortalWoundsTotal = mortals
self.total = max(0, wound + autoWoundsCarried - mortals)
autoWoundsCarried = 0
self.state = .armour self.state = .armour
saveHasRerolled = false saveHasRerolled = false
case .armour: case .armour:
@@ -348,6 +388,7 @@ class Roll: ObservableObject {
self.state = .hit self.state = .hit
hitHasRerolled = false hitHasRerolled = false
blowupUsed = false blowupUsed = false
mortalWoundsTotal = 0
} }
self.dice.roll(howMany: self.total) self.dice.roll(howMany: self.total)
} }
+21
View File
@@ -0,0 +1,21 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "DieHammerEngine",
products: [
.library(
name: "DieHammerEngine",
targets: ["DieHammerEngine"]
),
],
targets: [
.target(
name: "DieHammerEngine"
),
.testTarget(
name: "DieHammerEngineTests",
dependencies: ["DieHammerEngine"]
),
]
)
@@ -0,0 +1,205 @@
public struct Dice {
// MARK: Private
private var d6: Int {
Int.random(in: 1...6)
}
// MARK: Enums
public enum DiceType: Int, CaseIterable {
case one = 1
case two = 2
case three = 3
case four = 4
case five = 5
case six = 6
}
public enum RollModifiers: Int {
case negative = -1
case none = 0
case positive = 1
public func add() -> RollModifiers {
switch self {
case .negative: return .none
case .none: return .positive
case .positive: return .positive
}
}
public func remove() -> RollModifiers {
switch self {
case .negative: return .negative
case .none: return .negative
case .positive: return .none
}
}
}
// MARK: Variables
public var one = 0
public var two = 0
public var three = 0
public var four = 0
public var five = 0
public var six = 0
public init() {}
// MARK: Roll controllers
public mutating func roll(howMany: Int) {
clear()
var y = howMany
while y > 0 {
switch d6 {
case 1: one += 1
case 2: two += 1
case 3: three += 1
case 4: four += 1
case 5: five += 1
default: six += 1
}
y -= 1
}
}
// MARK: Calculations
public func total(cutoff: Int, modifier: RollModifiers) -> Int {
var total = six
switch modifier {
case .none:
switch cutoff {
case 5: total += five
case 4: total += four + five
case 3: total += three + four + five
case 2: total += two + three + four + five
default: break
}
case .negative:
switch cutoff {
case 4: total += five
case 3: total += four + five
case 2: total += three + four + five
case 1: total += two + three + four + five
default: break
}
case .positive:
switch cutoff {
case 6: total += five
case 5: total += four + five
case 4: total += three + four + five
case 3, 2: total += two + three + four + five
default: break
}
}
return total
}
public func hit(cutoff: Int, modifier: Int) -> Int {
total(cutoff: cutoff, modifier: RollModifiers(rawValue: modifier) ?? .none)
}
public func wound(cutoff: Int, modifier: Int) -> Int {
total(cutoff: cutoff, modifier: RollModifiers(rawValue: modifier) ?? .none)
}
public func armour(save: Int, armourPenetration: Int, invulnerable: Bool) -> Int {
var total = 0
switch invulnerable ? save : (save + armourPenetration) {
case 7...: total = six + five + four + three + two + one
case 6: total = five + four + three + two + one
case 5: total = four + three + two + one
case 4: total = three + two + one
case 3: total = two + one
default: total = one
}
return total
}
// MARK: Utilities
public mutating func clear() {
one = 0; two = 0; three = 0; four = 0; five = 0; six = 0
}
public mutating func add(pool: Dice) {
one += pool.one; two += pool.two; three += pool.three
four += pool.four; five += pool.five; six += pool.six
}
public func value(which: DiceType) -> Int {
switch which {
case .one: return one
case .two: return two
case .three: return three
case .four: return four
case .five: return five
case .six: return six
}
}
public mutating func increment(die: DiceType) {
switch die {
case .one: one += 1
case .two: two += 1
case .three: three += 1
case .four: four += 1
case .five: five += 1
case .six: six += 1
}
}
public mutating func decrement(die: DiceType) {
switch die {
case .one: if one > 0 { one -= 1 }
case .two: if two > 0 { two -= 1 }
case .three: if three > 0 { three -= 1 }
case .four: if four > 0 { four -= 1 }
case .five: if five > 0 { five -= 1 }
case .six: if six > 0 { six -= 1 }
}
}
public mutating func reroll(dice: DiceType) {
var rerolls = Dice()
switch dice {
case .one: rerolls.roll(howMany: one); one = 0; self.add(pool: rerolls)
case .two: rerolls.roll(howMany: two); two = 0; self.add(pool: rerolls)
case .three: rerolls.roll(howMany: three); three = 0; self.add(pool: rerolls)
case .four: rerolls.roll(howMany: four); four = 0; self.add(pool: rerolls)
case .five: rerolls.roll(howMany: five); five = 0; self.add(pool: rerolls)
case .six: rerolls.roll(howMany: six); six = 0; self.add(pool: rerolls)
}
}
public mutating func rerollBelow(cutoff: Int) {
var count = 0
for faceValue in 1..<cutoff {
guard let face = DiceType(rawValue: faceValue) else { continue }
count += value(which: face)
switch face {
case .one: one = 0
case .two: two = 0
case .three: three = 0
case .four: four = 0
case .five: five = 0
case .six: six = 0
}
}
var rerolls = Dice()
rerolls.roll(howMany: count)
add(pool: rerolls)
}
public mutating func blowup(dice: DiceType) {
var rolls = Dice()
if dice == .six {
rolls.roll(howMany: six)
self.add(pool: rolls)
} else if dice == .five {
rolls.roll(howMany: six + five)
self.add(pool: rolls)
}
}
}
@@ -0,0 +1,8 @@
public func woundCutoff(strength: Int, toughness: Int) -> Int {
let ratio = Float(strength) / Float(toughness)
if ratio >= 2.0 { return 2 }
else if ratio > 1.0 { return 3 }
else if ratio == 1.0 { return 4 }
else if ratio <= 0.5 { return 6 }
else { return 5 }
}
@@ -0,0 +1,71 @@
import Testing
@testable import DieHammerEngine
@Suite("Dice")
struct DiceTests {
func oneEach() -> Dice {
var d = Dice()
d.one = 1; d.two = 1; d.three = 1; d.four = 1; d.five = 1; d.six = 1
return d
}
@Test("roll preserves total count")
func rollCount() {
var d = Dice()
d.roll(howMany: 10)
let total = d.one + d.two + d.three + d.four + d.five + d.six
#expect(total == 10)
}
@Test("hit counts dice at or above cutoff (no modifier)")
func hitNoModifier() {
let d = oneEach()
#expect(d.hit(cutoff: 4, modifier: 0) == 3) // 4,5,6
#expect(d.hit(cutoff: 6, modifier: 0) == 1) // 6 only
}
@Test("armour: AP worsens save (higher switch value = more failures)")
func armourAP() {
let d = oneEach()
// 3+ save, AP-1 → effective 4+: dice 1,2,3 fail = 3
#expect(d.armour(save: 3, armourPenetration: 1, invulnerable: false) == 3)
}
@Test("armour: invulnerable ignores AP")
func armourInvulnerable() {
let d = oneEach()
let withInvuln = d.armour(save: 4, armourPenetration: 5, invulnerable: true)
let noAP = d.armour(save: 4, armourPenetration: 0, invulnerable: false)
#expect(withInvuln == noAP)
}
}
@Suite("WoundCutoff")
struct WoundCutoffTests {
@Test("S double T: wound on 2+")
func doubleStrength() {
#expect(woundCutoff(strength: 10, toughness: 5) == 2)
}
@Test("S greater than T: wound on 3+")
func strengthHigher() {
#expect(woundCutoff(strength: 7, toughness: 5) == 3)
}
@Test("S equals T: wound on 4+")
func equal() {
#expect(woundCutoff(strength: 5, toughness: 5) == 4)
}
@Test("T greater than S: wound on 5+")
func toughnessHigher() {
#expect(woundCutoff(strength: 3, toughness: 5) == 5)
}
@Test("T double S: wound on 6+")
func doubleToughness() {
#expect(woundCutoff(strength: 2, toughness: 5) == 6)
}
}
@@ -6,7 +6,7 @@
// //
import Testing import Testing
@testable import Bunch_O__Dice @testable import DieHammer
// MARK: - Dice Total Calculation // MARK: - Dice Total Calculation
@@ -65,28 +65,28 @@ struct ArmourSaveTests {
@Test("Counts dice that fail the save") @Test("Counts dice that fail the save")
func normalSaves() { func normalSaves() {
let d = oneEach() let d = oneEach()
#expect(d.armour(die: 6, invulnerable: false, save: 6, armourPenetration: 0) == 5) // 1-5 fail #expect(d.armour(save: 6, armourPenetration: 0, invulnerable: false) == 5) // 1-5 fail
#expect(d.armour(die: 6, invulnerable: false, save: 5, armourPenetration: 0) == 4) // 1-4 fail #expect(d.armour(save: 5, armourPenetration: 0, invulnerable: false) == 4) // 1-4 fail
#expect(d.armour(die: 6, invulnerable: false, save: 4, armourPenetration: 0) == 3) // 1-3 fail #expect(d.armour(save: 4, armourPenetration: 0, invulnerable: false) == 3) // 1-3 fail
#expect(d.armour(die: 6, invulnerable: false, save: 3, armourPenetration: 0) == 2) // 1-2 fail #expect(d.armour(save: 3, armourPenetration: 0, invulnerable: false) == 2) // 1-2 fail
#expect(d.armour(die: 6, invulnerable: false, save: 2, armourPenetration: 0) == 1) // only 1 fails #expect(d.armour(save: 2, armourPenetration: 0, invulnerable: false) == 1) // only 1 fails
} }
@Test("AP reduces effective save value") @Test("AP reduces effective save value")
func armourPenetration() { func armourPenetration() {
let d = oneEach() let d = oneEach()
// 3+ save with AP 1 → effective 4+: 1-3 fail // 3+ save with AP 1 → effective 4+: 1-3 fail
#expect(d.armour(die: 6, invulnerable: false, save: 3, armourPenetration: 1) == 3) #expect(d.armour(save: 3, armourPenetration: 1, invulnerable: false) == 3)
// 3+ save with AP 2 → effective 5+: 1-4 fail // 3+ save with AP 2 → effective 5+: 1-4 fail
#expect(d.armour(die: 6, invulnerable: false, save: 3, armourPenetration: 2) == 4) #expect(d.armour(save: 3, armourPenetration: 2, invulnerable: false) == 4)
} }
@Test("Invulnerable save ignores AP") @Test("Invulnerable save ignores AP")
func invulnerableSave() { func invulnerableSave() {
let d = oneEach() let d = oneEach()
// 4++ invulnerable with heavy AP should match a plain 4+ with no AP // 4++ invulnerable with heavy AP should match a plain 4+ with no AP
let withInvuln = d.armour(die: 6, invulnerable: true, save: 4, armourPenetration: 5) let withInvuln = d.armour(save: 4, armourPenetration: 5, invulnerable: true)
let noAP = d.armour(die: 6, invulnerable: false, save: 4, armourPenetration: 0) let noAP = d.armour(save: 4, armourPenetration: 0, invulnerable: false)
#expect(withInvuln == noAP) #expect(withInvuln == noAP)
} }
} }
@@ -361,3 +361,229 @@ struct BlowupTests {
#expect(totalAfter == totalBefore) #expect(totalAfter == totalBefore)
} }
} }
// MARK: - Critical Trigger Computed Vars
@Suite("Critical Trigger Computed Vars")
struct CriticalTriggerVarTests {
@Test("autoWoundCrits: Lethal Hits returns dice.six")
func lethalHitsCount() {
let roll = Roll()
roll.dice.four = 2; roll.dice.six = 3
roll.lethalHits = true
#expect(roll.autoWoundCrits == 3)
}
@Test("autoWoundCrits: Anti-X returns dice at or above threshold")
func antiXCount() {
let roll = Roll()
roll.dice.four = 1; roll.dice.five = 2; roll.dice.six = 2
roll.antiX = true; roll.antiXThreshold = 5
#expect(roll.autoWoundCrits == 4) // five(2) + six(2)
}
@Test("autoWoundCrits: Anti-X is superset of Lethal Hits when threshold < 6")
func antiXSupersetOfLethal() {
let roll = Roll()
roll.dice.five = 2; roll.dice.six = 3
roll.lethalHits = true; roll.antiX = true; roll.antiXThreshold = 5
// Anti-X(5+) = 5, Lethal = 3 — Anti-X is the superset, no double-count
#expect(roll.autoWoundCrits == 5)
}
@Test("autoWoundCrits: Anti-X and Lethal Hits both on threshold 6 — no double-count")
func antiXAndLethalSameThreshold() {
let roll = Roll()
roll.dice.six = 3
roll.lethalHits = true; roll.antiX = true; roll.antiXThreshold = 6
#expect(roll.autoWoundCrits == 3)
}
@Test("autoWoundCrits: Torrent gates out to zero even with Lethal Hits")
func torrentGatesLethal() {
let roll = Roll()
roll.dice.six = 4
roll.lethalHits = true; roll.torrent = true
#expect(roll.autoWoundCrits == 0)
}
@Test("autoWoundCrits: Torrent gates out Anti-X")
func torrentGatesAntiX() {
let roll = Roll()
roll.dice.five = 2; roll.dice.six = 2
roll.antiX = true; roll.antiXThreshold = 5; roll.torrent = true
#expect(roll.autoWoundCrits == 0)
}
@Test("antiXCount: threshold 6 equals dice.six")
func antiXCountThresholdSix() {
let roll = Roll()
roll.dice.five = 2; roll.dice.six = 3
roll.antiX = true; roll.antiXThreshold = 6
#expect(roll.antiXCount == 3)
}
@Test("antiXCount: threshold 5 includes fives and sixes")
func antiXCountThresholdFive() {
let roll = Roll()
roll.dice.four = 1; roll.dice.five = 2; roll.dice.six = 2
roll.antiX = true; roll.antiXThreshold = 5
#expect(roll.antiXCount == 4)
}
@Test("antiXCount: returns zero when Anti-X is off")
func antiXCountWhenOff() {
let roll = Roll()
roll.dice.six = 5
roll.antiX = false
#expect(roll.antiXCount == 0)
}
@Test("critWoundCount: returns dice.six when Devastating Wounds is on")
func critWoundCountOn() {
let roll = Roll()
roll.dice.six = 3
roll.devastatingWounds = true
#expect(roll.critWoundCount == 3)
}
@Test("critWoundCount: returns zero when Devastating Wounds is off")
func critWoundCountOff() {
let roll = Roll()
roll.dice.six = 3
roll.devastatingWounds = false
#expect(roll.critWoundCount == 0)
}
}
// MARK: - Critical Phase Transitions
@Suite("Critical Phase Transitions")
struct CritPhaseTransitionTests {
// Helpers: set up a roll with known hit dice that avoid random interference
// roll.total is set during incrementState before dice.roll is called — testable directly.
@Test("Lethal Hits: auto-wounds carried equals crits, wound pool shrinks")
func lethalHitsTransition() {
let roll = Roll()
roll.state = .hit
roll.hitCutoff = .four
// hit = four(1) + five(1) + six(2) = 4; crits = 2
roll.dice.four = 1; roll.dice.five = 1; roll.dice.six = 2
roll.lethalHits = true
roll.incrementState()
#expect(roll.autoWoundsCarried == 2)
#expect(roll.total == 2) // 4 hits - 2 crits
#expect(roll.state == .wound)
}
@Test("Sustained Hits: wound pool grows by six-count * X")
func sustainedHitsTransition() {
let roll = Roll()
roll.state = .hit
roll.hitCutoff = .four
roll.sustainedHitsX = 2
// hit = 4; sixes = 2 → sustained extra = 2*2 = 4
roll.dice.four = 1; roll.dice.five = 1; roll.dice.six = 2
roll.sustainedHits = true
roll.incrementState()
#expect(roll.autoWoundsCarried == 0)
#expect(roll.total == 8) // 4 hits + 4 sustained extra
#expect(roll.state == .wound)
}
@Test("Sustained Hits + Lethal Hits: extra hits go to wound roll, crits auto-wound")
func sustainedPlusLethalTransition() {
let roll = Roll()
roll.state = .hit
roll.hitCutoff = .four
roll.sustainedHitsX = 1
// hit = 4; crits (lethal) = 2; sustained extra = 2*1 = 2
// wound pool = (4-2) + 2 = 4
roll.dice.four = 1; roll.dice.five = 1; roll.dice.six = 2
roll.sustainedHits = true; roll.lethalHits = true
roll.incrementState()
#expect(roll.autoWoundsCarried == 2)
#expect(roll.total == 4) // 2 normal hits + 2 sustained extras
#expect(roll.state == .wound)
}
@Test("Anti-X: dice at/above threshold are carried as auto-wounds")
func antiXTransition() {
let roll = Roll()
roll.state = .hit
roll.hitCutoff = .four
// hit = 5; antiX(5+) = 4
roll.dice.four = 1; roll.dice.five = 2; roll.dice.six = 2
roll.antiX = true; roll.antiXThreshold = 5
roll.incrementState()
#expect(roll.autoWoundsCarried == 4)
#expect(roll.total == 1) // 5 hits - 4 crits
#expect(roll.state == .wound)
}
@Test("Torrent + Lethal Hits: no crits carried, full pool goes to wound")
func torrentBlocksCrits() {
let roll = Roll()
roll.state = .hit
roll.total = 10
roll.torrent = true; roll.lethalHits = true
roll.dice.six = 5
roll.incrementState()
#expect(roll.autoWoundsCarried == 0)
#expect(roll.total == 10) // hit = total (Torrent), no crits subtracted
#expect(roll.state == .wound)
}
@Test("Devastating Wounds: mortals recorded, save pool reduced")
func devastatingWoundsTransition() {
let roll = Roll()
roll.state = .wound
roll.strength = 5; roll.toughness = 5 // wound on 4+
// wound = four(1) + five(1) + six(2) = 4; mortals = 2
roll.dice.four = 1; roll.dice.five = 1; roll.dice.six = 2
roll.devastatingWounds = true
roll.incrementState()
#expect(roll.mortalWoundsTotal == 2)
#expect(roll.total == 2) // 4 wounds - 2 mortals
#expect(roll.state == .armour)
}
@Test("Auto-wounds carried through wound phase: added to save pool")
func autoWoundsAddedToSavePool() {
let roll = Roll()
roll.state = .wound
roll.strength = 5; roll.toughness = 5
roll.autoWoundsCarried = 3
// wound = four(1) + five(1) = 2; no devastatingWounds
roll.dice.four = 1; roll.dice.five = 1; roll.dice.six = 0
roll.incrementState()
#expect(roll.total == 5) // 2 wounds + 3 carried
#expect(roll.autoWoundsCarried == 0) // cleared
#expect(roll.state == .armour)
}
@Test("Wound→armour clears autoWoundsCarried")
func woundPhaseClearsCarry() {
let roll = Roll()
roll.state = .wound
roll.strength = 5; roll.toughness = 5
roll.autoWoundsCarried = 5
roll.dice.four = 1
roll.incrementState()
#expect(roll.autoWoundsCarried == 0)
}
@Test("Armour→hit clears mortalWoundsTotal")
func armourPhaseClears() {
let roll = Roll()
roll.state = .armour
roll.mortalWoundsTotal = 4
roll.total = 5
roll.incrementState()
#expect(roll.mortalWoundsTotal == 0)
#expect(roll.state == .hit)
}
}
+5 -5
View File
@@ -1,10 +1,10 @@
- [ ] Update graphics to clarify roll context (to hit / to wound / armor save) #focus/professional - [ ] Update graphics to clarify roll context (to hit / to wound / armor save) #focus/professional
- [x] Add stacking flat hit/wound modifier input, capped at net ±1 #focus/professional - [x] Add stacking flat hit/wound modifier input, capped at net ±1 #focus/professional
- [x] Add reroll mode selector (none / reroll 1s / reroll all failed / reroll all) #focus/professional - [x] Add reroll mode selector (none / reroll 1s / reroll all failed / reroll all) #focus/professional
- [ ] Add critical-trigger layer keyed off unmodified natural 6s (Sustained Hits, Lethal Hits, Devastating Wounds, Anti-X) #focus/professional - [x] Add critical-trigger layer keyed off unmodified natural 6s (Sustained Hits, Lethal Hits, Devastating Wounds, Anti-X) #focus/professional
- [ ] Add "ignore all modifiers" flag (Psychic) #focus/professional - [x] Add "ignore all modifiers" flag (Psychic) #focus/professional
- [ ] Add "auto-success, no roll" flag (Torrent) #focus/professional - [x] Add "auto-success, no roll" flag (Torrent) #focus/professional
- Android - Android
- [ ] Android: set up official Swift-on-Android toolchain. #focus/professional #android - [x] Android: set up official Swift-on-Android toolchain. #focus/professional #android
- [ ] Android: extract game logic (dice/modifier engine) into shared Swift library #focus/professional #android - [x] Android: extract game logic (dice/modifier engine) into shared Swift library #focus/professional #android
- [ ] Android: rewrite UI natively in Jetpack Compose #focus/professional #android - [ ] Android: rewrite UI natively in Jetpack Compose #focus/professional #android