diff --git a/Bunch-O--Dice/Model/Dice.swift b/Bunch-O--Dice/Model/Dice.swift deleted file mode 100644 index 7d03b7a..0000000 --- a/Bunch-O--Dice/Model/Dice.swift +++ /dev/null @@ -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.. 0 { + HStack { + Text("Mortal wounds: \(current.mortalWoundsTotal) (bypassed saves)") + .fontWeight(.semibold) + .foregroundColor(.orange) + Spacer() + } + .font(.system(size: 18)) + .padding(.horizontal) + } } .padding() } - + @ViewBuilder func woundCalculator(controlSize: Double) -> some View { 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) { Spacer() VStack { @@ -197,10 +217,25 @@ struct MainView: View { .font(.system(size: controlSize)) } .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() } - + @ViewBuilder func hitCalculator(controlSize: Double) -> some View { VStack { HStack(alignment: .top) { @@ -270,10 +305,65 @@ struct MainView: View { .opacity(current.torrent ? 0.4 : 1.0) } .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() } - + //MARK: die views @ViewBuilder func die(whichDie: Dice.DiceType) -> some View { let size = 22.0 @@ -282,13 +372,13 @@ struct MainView: View { let displayColorArmorText = (whichDie.rawValue < current.cutoff.rawValue) ? Color.red : Color.primary VStack(spacing: 2) { - Image(systemName: "die.face." + String(whichDie.rawValue) + ".fill") - .foregroundColor(current.state == .armour ? - displayColorArmor : displayColorHit) Text(String(current.dice.value(which: whichDie))) .foregroundColor(current.state == .armour ? displayColorArmorText : displayColorHit) .frame(minWidth: 30, alignment: .center) + Image(systemName: "die.face." + String(whichDie.rawValue) + ".fill") + .foregroundColor(current.state == .armour ? + displayColorArmor : displayColorHit) } .font(.system(size: size)) .padding(4) @@ -305,7 +395,7 @@ struct MainView: View { @ViewBuilder func diePool() -> some View { HStack(spacing: 0) { - ForEach(Dice.DiceType.allCases.reversed(), id: \.rawValue) { i in + ForEach(Dice.DiceType.allCases, id: \.rawValue) { i in die(whichDie: i) } } diff --git a/DieHammer/Model/Dice.swift b/DieHammer/Model/Dice.swift new file mode 100644 index 0000000..558538d --- /dev/null +++ b/DieHammer/Model/Dice.swift @@ -0,0 +1 @@ +@_exported import DieHammerEngine diff --git a/Bunch-O--Dice/Model/Onboarding.swift b/DieHammer/Model/Onboarding.swift similarity index 100% rename from Bunch-O--Dice/Model/Onboarding.swift rename to DieHammer/Model/Onboarding.swift diff --git a/Bunch-O--Dice/Model/RollState.swift b/DieHammer/Model/RollState.swift similarity index 83% rename from Bunch-O--Dice/Model/RollState.swift rename to DieHammer/Model/RollState.swift index db77b5c..82528c7 100644 --- a/Bunch-O--Dice/Model/RollState.swift +++ b/DieHammer/Model/RollState.swift @@ -154,7 +154,17 @@ class Roll: ObservableObject { @Published var blowupUsed = false @Published var torrent = 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 var totalDigits: [Int] { get { @@ -220,22 +230,46 @@ class Roll: ObservableObject { get { if torrent { return total } 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 { 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 { 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 { switch state { case .hit: return hitRerollMode @@ -336,11 +370,17 @@ class Roll: ObservableObject { } switch self.state { 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 woundHasRerolled = false case .wound: - self.total = wound + let mortals = critWoundCount + mortalWoundsTotal = mortals + self.total = max(0, wound + autoWoundsCarried - mortals) + autoWoundsCarried = 0 self.state = .armour saveHasRerolled = false case .armour: @@ -348,6 +388,7 @@ class Roll: ObservableObject { self.state = .hit hitHasRerolled = false blowupUsed = false + mortalWoundsTotal = 0 } self.dice.roll(howMany: self.total) } diff --git a/Bunch-O--Dice/OnboardingView.swift b/DieHammer/OnboardingView.swift similarity index 100% rename from Bunch-O--Dice/OnboardingView.swift rename to DieHammer/OnboardingView.swift diff --git a/Bunch-O--Dice/Preview Content/Preview Assets.xcassets/Contents.json b/DieHammer/Preview Content/Preview Assets.xcassets/Contents.json similarity index 100% rename from Bunch-O--Dice/Preview Content/Preview Assets.xcassets/Contents.json rename to DieHammer/Preview Content/Preview Assets.xcassets/Contents.json diff --git a/Bunch-O--Dice/PrivacyInfo.xcprivacy b/DieHammer/PrivacyInfo.xcprivacy similarity index 100% rename from Bunch-O--Dice/PrivacyInfo.xcprivacy rename to DieHammer/PrivacyInfo.xcprivacy diff --git a/Bunch-O--Dice/Utilities/ViewUtils.swift b/DieHammer/Utilities/ViewUtils.swift similarity index 100% rename from Bunch-O--Dice/Utilities/ViewUtils.swift rename to DieHammer/Utilities/ViewUtils.swift diff --git a/Bunch-O--Dice/Views/Die.swift b/DieHammer/Views/Die.swift similarity index 100% rename from Bunch-O--Dice/Views/Die.swift rename to DieHammer/Views/Die.swift diff --git a/Bunch-O--Dice/Views/LandscapeVariants/DieLandscape.swift b/DieHammer/Views/LandscapeVariants/DieLandscape.swift similarity index 100% rename from Bunch-O--Dice/Views/LandscapeVariants/DieLandscape.swift rename to DieHammer/Views/LandscapeVariants/DieLandscape.swift diff --git a/Bunch-O--Dice/Views/LandscapeVariants/ShowPoolLandscape.swift b/DieHammer/Views/LandscapeVariants/ShowPoolLandscape.swift similarity index 100% rename from Bunch-O--Dice/Views/LandscapeVariants/ShowPoolLandscape.swift rename to DieHammer/Views/LandscapeVariants/ShowPoolLandscape.swift diff --git a/Bunch-O--Dice/Views/Modals/AddToPool.swift b/DieHammer/Views/Modals/AddToPool.swift similarity index 100% rename from Bunch-O--Dice/Views/Modals/AddToPool.swift rename to DieHammer/Views/Modals/AddToPool.swift diff --git a/Bunch-O--Dice/Views/Modals/HistoryView.swift b/DieHammer/Views/Modals/HistoryView.swift similarity index 100% rename from Bunch-O--Dice/Views/Modals/HistoryView.swift rename to DieHammer/Views/Modals/HistoryView.swift diff --git a/Bunch-O--Dice/Views/Modals/SettingsView.swift b/DieHammer/Views/Modals/SettingsView.swift similarity index 100% rename from Bunch-O--Dice/Views/Modals/SettingsView.swift rename to DieHammer/Views/Modals/SettingsView.swift diff --git a/Bunch-O--Dice/Views/PortraitVariants/DieHorizontal.swift b/DieHammer/Views/PortraitVariants/DieHorizontal.swift similarity index 100% rename from Bunch-O--Dice/Views/PortraitVariants/DieHorizontal.swift rename to DieHammer/Views/PortraitVariants/DieHorizontal.swift diff --git a/Bunch-O--Dice/Views/PortraitVariants/ShowPoolHorizontal.swift b/DieHammer/Views/PortraitVariants/ShowPoolHorizontal.swift similarity index 100% rename from Bunch-O--Dice/Views/PortraitVariants/ShowPoolHorizontal.swift rename to DieHammer/Views/PortraitVariants/ShowPoolHorizontal.swift diff --git a/Bunch-O--Dice/Views/dice.swift b/DieHammer/Views/dice.swift similarity index 100% rename from Bunch-O--Dice/Views/dice.swift rename to DieHammer/Views/dice.swift diff --git a/Bunch-O--Dice/Views/oldLayout/PortraitScreen.swift b/DieHammer/Views/oldLayout/PortraitScreen.swift similarity index 100% rename from Bunch-O--Dice/Views/oldLayout/PortraitScreen.swift rename to DieHammer/Views/oldLayout/PortraitScreen.swift diff --git a/DieHammerEngine/Package.swift b/DieHammerEngine/Package.swift new file mode 100644 index 0000000..feeffc7 --- /dev/null +++ b/DieHammerEngine/Package.swift @@ -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"] + ), + ] +) diff --git a/DieHammerEngine/Sources/DieHammerEngine/Dice.swift b/DieHammerEngine/Sources/DieHammerEngine/Dice.swift new file mode 100644 index 0000000..b80f4d1 --- /dev/null +++ b/DieHammerEngine/Sources/DieHammerEngine/Dice.swift @@ -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.. 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 } +} diff --git a/DieHammerEngine/Tests/DieHammerEngineTests/DiceTests.swift b/DieHammerEngine/Tests/DieHammerEngineTests/DiceTests.swift new file mode 100644 index 0000000..5f9c291 --- /dev/null +++ b/DieHammerEngine/Tests/DieHammerEngineTests/DiceTests.swift @@ -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) + } +} diff --git a/Bunch O' DiceTests/Bunch_O__DiceTests.swift b/DieHammerTests/Bunch_O__DiceTests.swift similarity index 56% rename from Bunch O' DiceTests/Bunch_O__DiceTests.swift rename to DieHammerTests/Bunch_O__DiceTests.swift index 35baf54..c30b1b5 100644 --- a/Bunch O' DiceTests/Bunch_O__DiceTests.swift +++ b/DieHammerTests/Bunch_O__DiceTests.swift @@ -6,7 +6,7 @@ // import Testing -@testable import Bunch_O__Dice +@testable import DieHammer // MARK: - Dice Total Calculation @@ -65,28 +65,28 @@ struct ArmourSaveTests { @Test("Counts dice that fail the save") func normalSaves() { let d = oneEach() - #expect(d.armour(die: 6, invulnerable: false, save: 6, armourPenetration: 0) == 5) // 1-5 fail - #expect(d.armour(die: 6, invulnerable: false, save: 5, armourPenetration: 0) == 4) // 1-4 fail - #expect(d.armour(die: 6, invulnerable: false, save: 4, armourPenetration: 0) == 3) // 1-3 fail - #expect(d.armour(die: 6, invulnerable: false, save: 3, armourPenetration: 0) == 2) // 1-2 fail - #expect(d.armour(die: 6, invulnerable: false, save: 2, armourPenetration: 0) == 1) // only 1 fails + #expect(d.armour(save: 6, armourPenetration: 0, invulnerable: false) == 5) // 1-5 fail + #expect(d.armour(save: 5, armourPenetration: 0, invulnerable: false) == 4) // 1-4 fail + #expect(d.armour(save: 4, armourPenetration: 0, invulnerable: false) == 3) // 1-3 fail + #expect(d.armour(save: 3, armourPenetration: 0, invulnerable: false) == 2) // 1-2 fail + #expect(d.armour(save: 2, armourPenetration: 0, invulnerable: false) == 1) // only 1 fails } @Test("AP reduces effective save value") func armourPenetration() { let d = oneEach() // 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 - #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") func invulnerableSave() { let d = oneEach() // 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 noAP = d.armour(die: 6, invulnerable: false, save: 4, armourPenetration: 0) + let withInvuln = d.armour(save: 4, armourPenetration: 5, invulnerable: true) + let noAP = d.armour(save: 4, armourPenetration: 0, invulnerable: false) #expect(withInvuln == noAP) } } @@ -361,3 +361,229 @@ struct BlowupTests { #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) + } +} diff --git a/Bunch O' DiceUITests/Bunch_O__DiceUITests.swift b/DieHammerUITests/Bunch_O__DiceUITests.swift similarity index 100% rename from Bunch O' DiceUITests/Bunch_O__DiceUITests.swift rename to DieHammerUITests/Bunch_O__DiceUITests.swift diff --git a/Bunch O' DiceUITests/Bunch_O__DiceUITestsLaunchTests.swift b/DieHammerUITests/Bunch_O__DiceUITestsLaunchTests.swift similarity index 100% rename from Bunch O' DiceUITests/Bunch_O__DiceUITestsLaunchTests.swift rename to DieHammerUITests/Bunch_O__DiceUITestsLaunchTests.swift diff --git a/Goals.md b/Goals.md index 53890f8..64bc232 100644 --- a/Goals.md +++ b/Goals.md @@ -1,10 +1,10 @@ - [ ] 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 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 - - [ ] Add "ignore all modifiers" flag (Psychic) #focus/professional - - [ ] Add "auto-success, no roll" flag (Torrent) #focus/professional + - [x] Add critical-trigger layer keyed off unmodified natural 6s (Sustained Hits, Lethal Hits, Devastating Wounds, Anti-X) #focus/professional + - [x] Add "ignore all modifiers" flag (Psychic) #focus/professional + - [x] Add "auto-success, no roll" flag (Torrent) #focus/professional - Android - - [ ] 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: set up official Swift-on-Android toolchain. #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