From d93a5c50d1a86b08325319dd722e3149a60e594e Mon Sep 17 00:00:00 2001 From: Loic Prieto Date: Sun, 15 Jun 2025 23:15:13 +0200 Subject: [PATCH] Keep implementing attack scenario resolution --- pom.xml | 20 ++++ .../fourtykcalculator/AttackScenario.java | 97 ++++++++++++++++--- .../AttackScenarioResolution.java | 21 +++- .../FourtyKCalculatorApp.java | 16 +-- .../games/fourtykcalculator/Reroll.java | 41 +++++++- .../games/fourtykcalculator/Weapon.java | 2 + .../games/fourtykcalculator/WeaponStat.java | 21 ++++ .../utils/datastructures/Try.java | 4 +- .../utils/datastructures/Tuple3.java | 2 +- .../utils/datastructures/Tuple4.java | 2 +- .../games/fourtykcalculator/RerollTest.java | 32 ++++++ 11 files changed, 219 insertions(+), 39 deletions(-) create mode 100644 src/main/java/ninja/thefirearchmage/games/fourtykcalculator/WeaponStat.java create mode 100644 src/test/java/ninja/thefirearchmage/games/fourtykcalculator/RerollTest.java 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); + } +}