all features implemented
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||