Keep implementing attack scenario resolution

This commit is contained in:
Loic Prieto 2025-06-15 23:15:13 +02:00
parent 04cd4a50ff
commit d93a5c50d1
11 changed files with 219 additions and 39 deletions

20
pom.xml
View file

@ -29,6 +29,21 @@
<artifactId>javafx-controls</artifactId> <artifactId>javafx-controls</artifactId>
<version>24-ea+5</version> <version>24-ea+5</version>
</dependency> </dependency>
<!-- JUnit 5 (Jupiter) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@ -48,6 +63,11 @@
</annotationProcessorPaths> </annotationProcessorPaths>
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.1</version>
</plugin>
</plugins> </plugins>
</build> </build>
</project> </project>

View file

@ -6,15 +6,14 @@ import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple2
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class AttackScenario { public class AttackScenario {
private static final int PROBABILITY_DENOMINATOR_D6 = 6; private static final int PROBABILITY_DENOMINATOR_D6 = 6;
// Attack attributes // Attack attributes
private List<Tuple2<Weapon, Integer>> weapons; private List<Tuple2<Weapon, Integer>> weapons;
private Optional<Reroll> hitReroll;
private Optional<Reroll> woundReroll;
private Optional<Reroll> damageReroll;
private boolean unmodifiableHit; private boolean unmodifiableHit;
private boolean attackerWasStationary; private boolean attackerWasStationary;
private boolean attackerCharged; private boolean attackerCharged;
@ -22,6 +21,16 @@ public class AttackScenario {
private boolean hasHitRollBonus; private boolean hasHitRollBonus;
private boolean hasWoundRollBonus; private boolean hasWoundRollBonus;
private boolean hasLineOfSight; 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 // Defense attributes
private DefensiveProfile defensiveProfile; private DefensiveProfile defensiveProfile;
@ -32,12 +41,11 @@ public class AttackScenario {
private boolean woundRollPenaltyIfStrengthIsHigher; private boolean woundRollPenaltyIfStrengthIsHigher;
private int damageReduction; private int damageReduction;
public AttackScenario() { // Result
private AttackScenarioResolution results;
public AttackScenario() {
weapons = new ArrayList<>(); weapons = new ArrayList<>();
hitReroll = Optional.empty();
woundReroll = Optional.empty();
damageReroll = Optional.empty();
unmodifiableHit = false; unmodifiableHit = false;
attackerWasStationary = false; attackerWasStationary = false;
attackerCharged = true; attackerCharged = true;
@ -45,6 +53,16 @@ public class AttackScenario {
hasHitRollBonus = false; hasHitRollBonus = false;
hasWoundRollBonus = false; hasWoundRollBonus = false;
hasLineOfSight = true; hasLineOfSight = true;
hasRerollHitsOnes = false;
hasRerollHitsFailures = false;
hasRerollHitsAny = false;
hasRerollWoundsOnes = false;
hasRerollWoundsFailures = false;
hasRerollWoundsAny = false;
fishCriticalHits = false;
fishCriticalWounds = false;
criticalHitValue = 6;
criticalWoundValue = 6;
defensiveProfile = null; defensiveProfile = null;
normalSaveReroll = Optional.empty(); normalSaveReroll = Optional.empty();
@ -53,14 +71,16 @@ public class AttackScenario {
isInCover = false; isInCover = false;
woundRollPenaltyIfStrengthIsHigher = false; woundRollPenaltyIfStrengthIsHigher = false;
damageReduction = 0; 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)); weapons.add(Tuple.of(weaponType, numberOfWeaponsInUnit));
return this; return this;
} }
public AttackScenario setDefensiveProfile(DefensiveProfile defensiveProfile) { public AttackScenario withDefensiveProfile(DefensiveProfile defensiveProfile) {
this.defensiveProfile = defensiveProfile; this.defensiveProfile = defensiveProfile;
return this; return this;
} }
@ -79,17 +99,66 @@ public class AttackScenario {
// Attack probabilities // Attack probabilities
int finalHitRollGoal = calculateHitRollGoal(weapon); int finalHitRollGoal = calculateHitRollGoal(weapon);
int finalWoundRollGoal = calculateWoundRollGoal(weapon.getStrength(), defensiveProfile.getToughness(), weapon); int finalWoundRollGoal = calculateWoundRollGoal(weapon.getStrength(), defensiveProfile.getToughness(), weapon);
int hitSuccessProbabilityNumerator = calculateProbabilityNumeratorFrom(finalHitRollGoal); var passingHits = calculatePassingHits(weapon, amountOfWeapons, finalHitRollGoal);
int hitFailureProbabilityNumerator = 6 - hitSuccessProbabilityNumerator;
int passingHits = (weapon.getAttacks()*amountOfWeapons) * (hitSuccessProbabilityNumerator/PROBABILITY_DENOMINATOR_D6);
if(hitReroll.isPresent()) {
}
int woundSuccessProbabilityNumerator = calculateProbabilityNumeratorFrom(finalWoundRollGoal); 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<Reroll> 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) { private int calculateHitRollGoal(Weapon weapon) {
var finalHitRollGoal = weapon.getHitValue(); var finalHitRollGoal = weapon.getHitValue();
if(hasHitRollBonus) { if(hasHitRollBonus) {

View file

@ -5,17 +5,30 @@ import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple;
import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple2; import ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures.Tuple2;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import static java.lang.String.format;
@Getter @Getter
public class AttackScenarioResolution { public class AttackScenarioResolution {
private List<Tuple2<String, Double>> wounds; private Map<String, Map<WeaponStat, Object>> weaponStats;
public AttackScenarioResolution() { public AttackScenarioResolution() {
this.wounds = new ArrayList<>(); this.weaponStats = new HashMap<>();
} }
public void addWoundsFromWeapon(Weapon weapon, double inflictedWounds) { public void setWeaponStat(Weapon weapon, WeaponStat statName, Object statValue) {
this.wounds.add(Tuple.of(weapon.getName(), inflictedWounds)); 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);
}
} }
} }

View file

@ -8,8 +8,6 @@ import javafx.scene.control.TextArea;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.stage.Stage; import javafx.stage.Stage;
import java.util.Arrays;
public class FourtyKCalculatorApp extends Application { public class FourtyKCalculatorApp extends Application {
@Override @Override
public void start(Stage stage) throws Exception { public void start(Stage stage) throws Exception {
@ -20,20 +18,8 @@ public class FourtyKCalculatorApp extends Application {
Button calculateBtn = new Button("Calculate"); Button calculateBtn = new Button("Calculate");
calculateBtn.setOnAction(e -> { calculateBtn.setOnAction(e -> {
List<DefensiveProfile> 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(); output.setText("lol");
for (DefensiveProfile target : targets) {
sb.append(calculate(target, 3, true, true, true, "melta"));
sb.append("\n");
}
output.setText(sb.toString());
}); });
VBox layout = new VBox(10); VBox layout = new VBox(10);

View file

@ -1,7 +1,44 @@
package ninja.thefirearchmage.games.fourtykcalculator; 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 { public class Reroll {
private Set<Integer> rerollableValues; private final Set<Integer> rerollableValues;
private Reroll(Set<Integer> 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<Integer> 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;
}
} }

View file

@ -39,6 +39,8 @@ public class Weapon {
private boolean hasIndirectFire; private boolean hasIndirectFire;
@Getter @Getter
private Optional<Integer> melta; private Optional<Integer> melta;
@Getter
private Optional<Integer> rapidFire;
public boolean hasLethalHits() { public boolean hasLethalHits() {
return hasLethalHits; return hasLethalHits;

View file

@ -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;
}
}

View file

@ -1,7 +1,7 @@
package ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures; package ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures;
import com.sephire.soa.core.utils.functions.CheckedCallable; import ninja.thefirearchmage.games.fourtykcalculator.utils.functions.CheckedCallable;
import com.sephire.soa.core.utils.functions.CheckedRunnable; import ninja.thefirearchmage.games.fourtykcalculator.utils.functions.CheckedRunnable;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;

View file

@ -1,6 +1,6 @@
package ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures; 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.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.ToString; import lombok.ToString;

View file

@ -1,6 +1,6 @@
package ninja.thefirearchmage.games.fourtykcalculator.utils.datastructures; 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.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.ToString; import lombok.ToString;

View file

@ -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);
}
}