diff --git a/pom.xml b/pom.xml
index 549fe22..153622a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -29,6 +29,21 @@
javafx-controls
24-ea+5
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.10.5
+ test
+
+
+ org.assertj
+ assertj-core
+ 3.26.3
+ test
+
+
@@ -48,6 +63,11 @@
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.1
+
\ No newline at end of file
diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenario.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenario.java
index 7b32381..f2d85e8 100644
--- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenario.java
+++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenario.java
@@ -6,15 +6,14 @@ import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple2
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
public class AttackScenario {
private static final int PROBABILITY_DENOMINATOR_D6 = 6;
// Attack attributes
private List> weapons;
- private Optional hitReroll;
- private Optional woundReroll;
- private Optional damageReroll;
private boolean unmodifiableHit;
private boolean attackerWasStationary;
private boolean attackerCharged;
@@ -22,6 +21,16 @@ public class AttackScenario {
private boolean hasHitRollBonus;
private boolean hasWoundRollBonus;
private boolean hasLineOfSight;
+ private boolean hasRerollHitsOnes;
+ private boolean hasRerollHitsFailures;
+ private boolean hasRerollHitsAny;
+ private boolean hasRerollWoundsOnes;
+ private boolean hasRerollWoundsFailures;
+ private boolean hasRerollWoundsAny;
+ private boolean fishCriticalHits;
+ private boolean fishCriticalWounds;
+ private int criticalHitValue;
+ private int criticalWoundValue;
// Defense attributes
private DefensiveProfile defensiveProfile;
@@ -32,12 +41,11 @@ public class AttackScenario {
private boolean woundRollPenaltyIfStrengthIsHigher;
private int damageReduction;
- public AttackScenario() {
+ // Result
+ private AttackScenarioResolution results;
+ public AttackScenario() {
weapons = new ArrayList<>();
- hitReroll = Optional.empty();
- woundReroll = Optional.empty();
- damageReroll = Optional.empty();
unmodifiableHit = false;
attackerWasStationary = false;
attackerCharged = true;
@@ -45,6 +53,16 @@ public class AttackScenario {
hasHitRollBonus = false;
hasWoundRollBonus = false;
hasLineOfSight = true;
+ hasRerollHitsOnes = false;
+ hasRerollHitsFailures = false;
+ hasRerollHitsAny = false;
+ hasRerollWoundsOnes = false;
+ hasRerollWoundsFailures = false;
+ hasRerollWoundsAny = false;
+ fishCriticalHits = false;
+ fishCriticalWounds = false;
+ criticalHitValue = 6;
+ criticalWoundValue = 6;
defensiveProfile = null;
normalSaveReroll = Optional.empty();
@@ -53,14 +71,16 @@ public class AttackScenario {
isInCover = false;
woundRollPenaltyIfStrengthIsHigher = false;
damageReduction = 0;
+
+ results = new AttackScenarioResolution();
}
- public AttackScenario withWeapon(Weapon weaponType, int numberOfWeaponsInUnit) {
+ public AttackScenario addWeapon(Weapon weaponType, int numberOfWeaponsInUnit) {
weapons.add(Tuple.of(weaponType, numberOfWeaponsInUnit));
return this;
}
- public AttackScenario setDefensiveProfile(DefensiveProfile defensiveProfile) {
+ public AttackScenario withDefensiveProfile(DefensiveProfile defensiveProfile) {
this.defensiveProfile = defensiveProfile;
return this;
}
@@ -79,17 +99,66 @@ public class AttackScenario {
// Attack probabilities
int finalHitRollGoal = calculateHitRollGoal(weapon);
int finalWoundRollGoal = calculateWoundRollGoal(weapon.getStrength(), defensiveProfile.getToughness(), weapon);
- int hitSuccessProbabilityNumerator = calculateProbabilityNumeratorFrom(finalHitRollGoal);
- int hitFailureProbabilityNumerator = 6 - hitSuccessProbabilityNumerator;
- int passingHits = (weapon.getAttacks()*amountOfWeapons) * (hitSuccessProbabilityNumerator/PROBABILITY_DENOMINATOR_D6);
- if(hitReroll.isPresent()) {
+ var passingHits = calculatePassingHits(weapon, amountOfWeapons, finalHitRollGoal);
- }
int woundSuccessProbabilityNumerator = calculateProbabilityNumeratorFrom(finalWoundRollGoal);
}
}
+ private double calculatePassingHits(Weapon weapon, int amountOfWeapons, int hitRollGoal) {
+ int hitSuccessProbabilityNumerator = calculateProbabilityNumeratorFrom(hitRollGoal);
+ int hitFailureProbabilityNumerator = 6 - hitSuccessProbabilityNumerator;
+ double weaponAttacks = calculateWeaponAttacks(weapon);
+
+ // Base passing hits without rerolls
+ double passingHits = (weaponAttacks*amountOfWeapons) * ((double) hitSuccessProbabilityNumerator / PROBABILITY_DENOMINATOR_D6);
+
+ // Add hits from rerolls
+ var hitRerolls = prepareHitRerolls(hitRollGoal);
+ if(hitRerolls.isPresent()) {
+ var rerolls = hitRerolls.get();
+ passingHits += rerolls.getProbabilityNumerator() * ((double) hitSuccessProbabilityNumerator / PROBABILITY_DENOMINATOR_D6);
+ }
+
+ return passingHits;
+ }
+
+ private Optional prepareHitRerolls(int hitRollGoal) {
+ if(hasRerollHitsOnes) {
+ return Optional.of(Reroll.rerollOnOnes());
+ }
+ if(hasRerollHitsFailures || hasRerollHitsAny) {
+ return Optional.of(Reroll.rerollFailures(hitRollGoal));
+ }
+
+ return Optional.empty();
+ }
+
+ private double calculateWeaponAttacks(Weapon weapon) {
+ double weaponAttacks = weapon.getAttacks();
+ if(weapon.getRapidFire().isPresent() && attackerInRapidFireRange) {
+ weaponAttacks += weapon.getRapidFire().get();
+ }
+ results.setWeaponStat(weapon, WeaponStat.POTENTIAL_HITS_COUNT, weaponAttacks);
+
+ // Add sustained hits
+ if(weapon.getSustainedHits().isPresent()) {
+ var sustainedHitsValue = weapon.getSustainedHits().get();
+
+ double nonSustainedProbability = (double) (6 - (7 - criticalHitValue)) /PROBABILITY_DENOMINATOR_D6;
+ double sustainedProbability = (double) (7 - criticalHitValue) /PROBABILITY_DENOMINATOR_D6;
+ if(fishCriticalHits && hasRerollHitsAny) {
+ sustainedProbability += nonSustainedProbability*sustainedProbability;
+ }
+ var sustainedHitsAmount = weaponAttacks * sustainedProbability * sustainedHitsValue;
+ results.setWeaponStat(weapon, WeaponStat.SUSTAINED_HITS_COUNT, sustainedHitsAmount);
+ weaponAttacks += sustainedHitsAmount;
+ }
+
+ return weaponAttacks;
+ }
+
private int calculateHitRollGoal(Weapon weapon) {
var finalHitRollGoal = weapon.getHitValue();
if(hasHitRollBonus) {
diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenarioResolution.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenarioResolution.java
index 1963051..f074a36 100644
--- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenarioResolution.java
+++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/AttackScenarioResolution.java
@@ -5,17 +5,30 @@ import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple;
import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple2;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+
+import static java.lang.String.format;
@Getter
public class AttackScenarioResolution {
- private List> wounds;
+ private Map> weaponStats;
public AttackScenarioResolution() {
- this.wounds = new ArrayList<>();
+ this.weaponStats = new HashMap<>();
}
- public void addWoundsFromWeapon(Weapon weapon, double inflictedWounds) {
- this.wounds.add(Tuple.of(weapon.getName(), inflictedWounds));
+ public void setWeaponStat(Weapon weapon, WeaponStat statName, Object statValue) {
+ if(!statValue.getClass().isAssignableFrom(statName.getStatClass())) {
+ throw new IllegalArgumentException(
+ format("The stat %s's value does not have the expected class %s, but instead has %s class",
+ statName.name(), statName.getStatClass().getName(), statValue.getClass().getName()));
+ }
+
+ var currentStats = weaponStats.putIfAbsent(weapon.getName(), Map.of(statName, statValue));
+ if(currentStats != null) {
+ currentStats.put(statName, statValue);
+ }
}
}
diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/FourtyKCalculatorApp.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/FourtyKCalculatorApp.java
index dddea81..8a75244 100644
--- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/FourtyKCalculatorApp.java
+++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/FourtyKCalculatorApp.java
@@ -8,8 +8,6 @@ import javafx.scene.control.TextArea;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
-import java.util.Arrays;
-
public class FourtyKCalculatorApp extends Application {
@Override
public void start(Stage stage) throws Exception {
@@ -20,20 +18,8 @@ public class FourtyKCalculatorApp extends Application {
Button calculateBtn = new Button("Calculate");
calculateBtn.setOnAction(e -> {
- List targets = Arrays.asList(
- new DefensiveProfile("Rhino", 9, 3, null, 10),
- new DefensiveProfile("Lehman Russ", 11, 2, null, 13),
- new DefensiveProfile("Redemptor Dread", 10, 3, 5, 12),
- new DefensiveProfile("Knight Paladin", 13, 2, 5, 22),
- new DefensiveProfile("Plagueburst Crawler", 12, 3, 4, 12)
- );
- StringBuilder sb = new StringBuilder();
- for (DefensiveProfile target : targets) {
- sb.append(calculate(target, 3, true, true, true, "melta"));
- sb.append("\n");
- }
- output.setText(sb.toString());
+ output.setText("lol");
});
VBox layout = new VBox(10);
diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/Reroll.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/Reroll.java
index 0f274b8..1789775 100644
--- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/Reroll.java
+++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/Reroll.java
@@ -1,7 +1,44 @@
package ninja.thefirearchmage.games.fourtykcalculator;
-import java.util.Set;
+import lombok.Getter;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+/**
+ * Assumes a D6
+ */
+@Getter
public class Reroll {
- private Set rerollableValues;
+ private final Set rerollableValues;
+
+ private Reroll(Set rerollableValues) {
+ this.rerollableValues = rerollableValues;
+ }
+
+ public static Reroll rerollOnOnes() {
+ return new Reroll(Set.of(1));
+ }
+
+ public static Reroll rerollFailures(int targetHit) {
+ var rerollableValues = IntStream.range(1, targetHit).boxed()
+ .collect(Collectors.toSet());
+
+ return new Reroll(rerollableValues);
+ }
+
+ public static Reroll rerollOnNot(Set allowedValues) {
+ var rerollableValues = IntStream.range(1,7).filter(i-> !allowedValues.contains(i))
+ .boxed().collect(Collectors.toSet());
+
+ return new Reroll(rerollableValues);
+ }
+
+ /**
+ * From a D6 returns what proportion of rerollable values this reroll is.
+ */
+ public double getProbabilityNumerator() {
+ return (double) rerollableValues.size() / 6;
+ }
}
diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/Weapon.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/Weapon.java
index 07272e9..1ed71cc 100644
--- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/Weapon.java
+++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/Weapon.java
@@ -39,6 +39,8 @@ public class Weapon {
private boolean hasIndirectFire;
@Getter
private Optional melta;
+ @Getter
+ private Optional rapidFire;
public boolean hasLethalHits() {
return hasLethalHits;
diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/WeaponStat.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/WeaponStat.java
new file mode 100644
index 0000000..091800c
--- /dev/null
+++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/WeaponStat.java
@@ -0,0 +1,21 @@
+package ninja.thefirearchmage.games.fourtykcalculator;
+
+import lombok.Getter;
+
+@Getter
+public enum WeaponStat {
+ HITS_COUNT(Double.class),
+ POTENTIAL_HITS_COUNT(Double.class),
+ WOUNDS_COUNT(Double.class),
+ MORTAL_WOUNDS_COUNT(Double.class),
+ TOTAL_DAMAGE(Double.class),
+ LETHAL_HITS_COUNT(Double.class),
+ SUSTAINED_HITS_COUNT(Double.class);
+
+ private final Class> statClass;
+
+ WeaponStat(Class> statClass) {
+ this.statClass = statClass;
+ }
+
+}
diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/utils/datastructures/Try.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/utils/datastructures/Try.java
index f500381..63b8ff8 100644
--- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/utils/datastructures/Try.java
+++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/utils/datastructures/Try.java
@@ -1,7 +1,7 @@
package ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures;
-import com.sephire.soa.core.utils.functions.CheckedCallable;
-import com.sephire.soa.core.utils.functions.CheckedRunnable;
+import ninja.thefirearchmage.games.fourtykcalculator.utils.functions.CheckedCallable;
+import ninja.thefirearchmage.games.fourtykcalculator.utils.functions.CheckedRunnable;
import java.util.ArrayList;
import java.util.List;
diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/utils/datastructures/Tuple3.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/utils/datastructures/Tuple3.java
index 284ba5e..18fe456 100644
--- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/utils/datastructures/Tuple3.java
+++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/utils/datastructures/Tuple3.java
@@ -1,6 +1,6 @@
package ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures;
-import com.sephire.soa.core.utils.functions.Function3;
+import ninja.thefirearchmage.games.fourtykcalculator.utils.functions.Function3;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
diff --git a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/utils/datastructures/Tuple4.java b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/utils/datastructures/Tuple4.java
index 5490723..700f0a5 100644
--- a/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/utils/datastructures/Tuple4.java
+++ b/src/main/java/ninja/thefirearchmage/games/fourtykcalculator/utils/datastructures/Tuple4.java
@@ -1,6 +1,6 @@
package ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures;
-import com.sephire.soa.core.utils.functions.Function4;
+import ninja.thefirearchmage.games.fourtykcalculator.utils.functions.Function4;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
diff --git a/src/test/java/ninja/thefirearchmage/games/fourtykcalculator/RerollTest.java b/src/test/java/ninja/thefirearchmage/games/fourtykcalculator/RerollTest.java
new file mode 100644
index 0000000..b7f718e
--- /dev/null
+++ b/src/test/java/ninja/thefirearchmage/games/fourtykcalculator/RerollTest.java
@@ -0,0 +1,32 @@
+package ninja.thefirearchmage.games.fourtykcalculator;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RerollTest {
+
+ @Test
+ void testFailureRerollGenerationSucceeds() {
+ var successHitValue = 4;
+ var failureReroll = Reroll.rerollFailures(successHitValue);
+ var expectedRerollableValues = Set.of(1,2,3);
+ assertThat(failureReroll.getRerollableValues()).isEqualTo(expectedRerollableValues);
+ }
+
+ @Test
+ void testRerollOnes() {
+ var rerollOnes = Reroll.rerollOnOnes();
+ var expectedRerollableValues = Set.of(1);
+ assertThat(rerollOnes.getRerollableValues()).isEqualTo(expectedRerollableValues);
+ }
+
+ @Test
+ void testFishingFives() {
+ var rerollToFishFives = Reroll.rerollOnNot(Set.of(5,6));
+ var expectedRerollableValues = Set.of(1,2,3,4);
+ assertThat(rerollToFishFives.getRerollableValues()).isEqualTo(expectedRerollableValues);
+ }
+}